@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.
- package/API.md +3 -2
- package/AUTH.md +27 -2
- package/CHANGELOG.md +11 -0
- package/CONTRIBUTING.md +6 -4
- package/DELIVERY.md +14 -1
- package/INTEGRATIONS.md +36 -6
- package/PROVIDER.md +9 -7
- package/README.md +11 -4
- package/RELEASING.md +1 -1
- package/STORAGE.md +15 -3
- package/dist/adapter-contract.js +1 -1
- package/dist/auth/client-auth.d.ts +10 -7
- package/dist/auth/client-auth.js +211 -83
- package/dist/auth/sqlite-store.js +16 -2
- package/dist/conversation.js +12 -2
- package/dist/index.d.ts +4 -1
- package/dist/index.js +1 -1
- package/dist/integrations.d.ts +6 -1
- package/dist/network/discovery.d.ts +20 -9
- package/dist/network/discovery.js +74 -21
- package/dist/network/redirect.d.ts +3 -0
- package/dist/network/redirect.js +38 -0
- package/dist/network/tasks.d.ts +23 -0
- package/dist/network/tasks.js +45 -0
- package/dist/postgres.js +14 -1
- package/dist/protocol/wire.d.ts +12 -11
- package/dist/protocol/wire.js +33 -7
- package/dist/provider.d.ts +2 -9
- package/dist/provider.js +15 -25
- package/examples/quickstart.mjs +1 -1
- package/package.json +2 -2
|
@@ -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');
|
package/dist/conversation.js
CHANGED
|
@@ -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
|
-
|
|
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';
|
package/dist/integrations.d.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
9
|
+
protocol_version: '0.3';
|
|
5
10
|
organization: {
|
|
6
11
|
name: string;
|
|
7
12
|
domain: string;
|
|
8
13
|
};
|
|
9
14
|
auth?: {
|
|
10
15
|
issuer: string;
|
|
11
|
-
direct?: {
|
|
12
|
-
scopes: string[];
|
|
13
|
-
};
|
|
14
|
-
device?: {
|
|
15
|
-
scopes: string[];
|
|
16
|
-
};
|
|
17
16
|
mediated?: {
|
|
18
17
|
endpoint: string;
|
|
19
18
|
fields: {
|
|
@@ -23,7 +22,8 @@ export interface PoppyDocument {
|
|
|
23
22
|
}[];
|
|
24
23
|
scopes: string[];
|
|
25
24
|
};
|
|
26
|
-
|
|
25
|
+
scopes?: Record<string, string>;
|
|
26
|
+
scope_hints?: Record<string, ScopeHint>;
|
|
27
27
|
[key: string]: unknown;
|
|
28
28
|
};
|
|
29
29
|
agent?: {
|
|
@@ -45,6 +45,9 @@ export interface PoppyDocument {
|
|
|
45
45
|
browser_session_endpoint?: string;
|
|
46
46
|
[key: string]: unknown;
|
|
47
47
|
};
|
|
48
|
+
excluded_tasks?: {
|
|
49
|
+
url: string;
|
|
50
|
+
};
|
|
48
51
|
feedback?: {
|
|
49
52
|
endpoint: string;
|
|
50
53
|
[key: string]: unknown;
|
|
@@ -60,7 +63,9 @@ export interface AuthorizationMetadata {
|
|
|
60
63
|
issuer: string;
|
|
61
64
|
token_endpoint: string;
|
|
62
65
|
revocation_endpoint: string;
|
|
63
|
-
|
|
66
|
+
protected_resources: string[];
|
|
67
|
+
grant_types_supported: string[];
|
|
68
|
+
scopes_supported?: string[];
|
|
64
69
|
authorization_endpoint?: string;
|
|
65
70
|
device_authorization_endpoint?: string;
|
|
66
71
|
dpop_signing_alg_values_supported?: string[];
|
|
@@ -86,12 +91,18 @@ export interface MetadataResolver {
|
|
|
86
91
|
resolveJwks(uri: string, options?: {
|
|
87
92
|
refresh?: boolean;
|
|
88
93
|
}): Promise<JsonObject>;
|
|
94
|
+
resolveExcludedTasks?(document: PoppyDocument): Promise<ExcludedTask[]>;
|
|
89
95
|
invalidate?(target?: string): void;
|
|
90
96
|
}
|
|
97
|
+
export declare function companyScopes(value: unknown, allowEmpty?: boolean): string[];
|
|
91
98
|
export declare function authorizationMetadataUrl(issuer: string): string;
|
|
92
99
|
export declare function validateClientId(clientId: string): URL;
|
|
93
100
|
export declare function parsePoppyDocument(value: unknown, servingUrl: string): PoppyDocument;
|
|
94
101
|
export declare function parseAuthorizationMetadata(value: unknown, document: PoppyDocument): AuthorizationMetadata;
|
|
102
|
+
export declare function signInMethods(metadata: CompanyMetadata): {
|
|
103
|
+
method: 'direct' | 'device' | 'mediated';
|
|
104
|
+
scopes: string[];
|
|
105
|
+
}[];
|
|
95
106
|
export declare function parseClientMetadata(value: unknown, clientId: string): ClientMetadata;
|
|
96
107
|
export declare function createMetadataResolver(transport: HttpTransport, options?: {
|
|
97
108
|
now?: () => number;
|
|
@@ -1,11 +1,17 @@
|
|
|
1
|
+
import { validRedirect } from './redirect.js';
|
|
1
2
|
import { object, string, member, canonical, parseJson, ValidationError } from '../protocol/wire.js';
|
|
2
3
|
import { secureUrl } from './transport.js';
|
|
4
|
+
import { parseExcludedTasks } from './tasks.js';
|
|
3
5
|
function url(value, field) { const valueString = string(value, field); secureUrl(valueString); return valueString; }
|
|
4
6
|
function strings(value, field, allowEmpty = false) { if (!Array.isArray(value) || (!allowEmpty && !value.length))
|
|
5
7
|
throw new ValidationError(field); value.forEach(item => string(item, field)); return value; }
|
|
6
|
-
function
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
export function companyScopes(value, allowEmpty = false) {
|
|
9
|
+
const result = strings(value, 'scopes', allowEmpty);
|
|
10
|
+
for (const scope of result)
|
|
11
|
+
if (!/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope) || scope.startsWith('poppy:'))
|
|
12
|
+
throw new ValidationError('scopes');
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
9
15
|
function domain(value) { const text = string(value, 'domain'); if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i.test(text) || text.includes('..'))
|
|
10
16
|
throw new ValidationError('domain'); return text; }
|
|
11
17
|
export function authorizationMetadataUrl(issuer) { const parsed = secureUrl(issuer); if (parsed.search)
|
|
@@ -21,7 +27,10 @@ export function validateClientId(clientId) {
|
|
|
21
27
|
}
|
|
22
28
|
export function parsePoppyDocument(value, servingUrl) {
|
|
23
29
|
const doc = object(value);
|
|
24
|
-
member(doc.protocol_version, ['0.
|
|
30
|
+
member(doc.protocol_version, ['0.3'], 'protocol_version');
|
|
31
|
+
const served = secureUrl(servingUrl);
|
|
32
|
+
if (served.pathname !== '/.well-known/poppy.json' || servingUrl.includes('?'))
|
|
33
|
+
throw new ValidationError('discovery.url');
|
|
25
34
|
const organization = object(doc.organization, 'organization');
|
|
26
35
|
string(organization.name, 'organization.name');
|
|
27
36
|
if (domain(organization.domain).toLowerCase() !== secureUrl(servingUrl).hostname.toLowerCase())
|
|
@@ -36,13 +45,10 @@ export function parsePoppyDocument(value, servingUrl) {
|
|
|
36
45
|
if ('auth' in doc) {
|
|
37
46
|
const auth = object(doc.auth, 'auth');
|
|
38
47
|
authorizationMetadataUrl(url(auth.issuer, 'issuer'));
|
|
39
|
-
for (const key of ['direct', 'device'])
|
|
40
|
-
if (key in auth)
|
|
41
|
-
scopes(object(auth[key], key).scopes);
|
|
42
48
|
if ('mediated' in auth) {
|
|
43
49
|
const mediated = object(auth.mediated, 'mediated');
|
|
44
50
|
url(mediated.endpoint, 'mediated.endpoint');
|
|
45
|
-
|
|
51
|
+
companyScopes(mediated.scopes);
|
|
46
52
|
if (!Array.isArray(mediated.fields) || !mediated.fields.length)
|
|
47
53
|
throw new ValidationError('mediated.fields');
|
|
48
54
|
for (const field of mediated.fields) {
|
|
@@ -52,8 +58,21 @@ export function parsePoppyDocument(value, servingUrl) {
|
|
|
52
58
|
member(item.secret, [true, false], 'field.secret');
|
|
53
59
|
}
|
|
54
60
|
}
|
|
55
|
-
if ('
|
|
56
|
-
|
|
61
|
+
if ('scope_hints' in auth) {
|
|
62
|
+
const hints = object(auth.scope_hints, 'scope_hints');
|
|
63
|
+
for (const [scope, value] of Object.entries(hints)) {
|
|
64
|
+
companyScopes([scope]);
|
|
65
|
+
const hint = object(value, 'scope_hint');
|
|
66
|
+
string(hint.changes, 'changes');
|
|
67
|
+
member(hint.personal_data, [true, false], 'personal_data');
|
|
68
|
+
hints[scope] = { changes: ['none', 'reversible', 'irreversible'].includes(hint.changes) ? hint.changes : 'irreversible', personal_data: hint.personal_data };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if ('scopes' in auth)
|
|
72
|
+
for (const [scope, description] of Object.entries(object(auth.scopes, 'auth.scopes'))) {
|
|
73
|
+
companyScopes([scope]);
|
|
74
|
+
string(description, 'auth.scopes');
|
|
75
|
+
}
|
|
57
76
|
}
|
|
58
77
|
if ('agent' in doc) {
|
|
59
78
|
const protocols = object(doc.agent, 'agent').protocols;
|
|
@@ -79,6 +98,8 @@ export function parsePoppyDocument(value, servingUrl) {
|
|
|
79
98
|
url(item.resource, 'api.resource');
|
|
80
99
|
}
|
|
81
100
|
}
|
|
101
|
+
if ('excluded_tasks' in doc)
|
|
102
|
+
url(object(doc.excluded_tasks).url, 'excluded_tasks.url');
|
|
82
103
|
if ('feedback' in doc)
|
|
83
104
|
url(object(doc.feedback).endpoint, 'feedback.endpoint');
|
|
84
105
|
if ('extensions' in doc)
|
|
@@ -95,21 +116,46 @@ export function parseAuthorizationMetadata(value, document) {
|
|
|
95
116
|
const auth = document.auth;
|
|
96
117
|
if (metadata.issuer !== auth.issuer)
|
|
97
118
|
throw new ValidationError('issuer');
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
if (!
|
|
101
|
-
throw new ValidationError('
|
|
119
|
+
const resources = strings(metadata.protected_resources, 'protected_resources');
|
|
120
|
+
resources.forEach(value => url(value, 'protected_resources'));
|
|
121
|
+
if (!resources.some(value => /^https:\/\/[^/:?#]+$/.test(value) && value.slice(8).toLowerCase() === document.organization.domain.toLowerCase()))
|
|
122
|
+
throw new ValidationError('protected_resources');
|
|
123
|
+
const grants = strings(metadata.grant_types_supported, 'grant_types_supported');
|
|
124
|
+
for (const grant of ['urn:ietf:params:oauth:grant-type:jwt-bearer', 'refresh_token'])
|
|
125
|
+
if (!grants.includes(grant))
|
|
126
|
+
throw new ValidationError('grant_types_supported');
|
|
102
127
|
for (const key of ['token_endpoint', 'revocation_endpoint'])
|
|
103
128
|
url(metadata[key], key);
|
|
104
|
-
for (const [key,
|
|
105
|
-
if (
|
|
129
|
+
for (const [key, grant] of [['authorization_endpoint', 'authorization_code'], ['device_authorization_endpoint', 'urn:ietf:params:oauth:grant-type:device_code']])
|
|
130
|
+
if (grants.includes(grant) || key in metadata)
|
|
106
131
|
url(metadata[key], key);
|
|
132
|
+
if ('pushed_authorization_request_endpoint' in metadata)
|
|
133
|
+
url(metadata.pushed_authorization_request_endpoint, 'pushed_authorization_request_endpoint');
|
|
134
|
+
const signIn = grants.includes('authorization_code') || grants.includes('urn:ietf:params:oauth:grant-type:device_code') || !!auth.mediated;
|
|
135
|
+
const supported = signIn || 'scopes_supported' in metadata ? companyScopes(metadata.scopes_supported, true) : [];
|
|
136
|
+
if (auth.mediated && !auth.mediated.scopes.every(scope => supported.includes(scope)))
|
|
137
|
+
throw new ValidationError('mediated.scopes');
|
|
138
|
+
// Scope descriptions may instead live in a listed OpenAPI document.
|
|
139
|
+
if (!document.apis?.some(api => api.type === 'openapi') && supported.some(scope => !Object.hasOwn(auth.scopes ?? {}, scope)))
|
|
140
|
+
throw new ValidationError('auth.scopes');
|
|
107
141
|
if ('dpop_signing_alg_values_supported' in metadata && !strings(metadata.dpop_signing_alg_values_supported, 'dpop_signing_alg_values_supported').includes('ES256'))
|
|
108
142
|
throw new ValidationError('dpop_signing_alg_values_supported');
|
|
109
143
|
if ('client_id_metadata_document_supported' in metadata)
|
|
110
144
|
member(metadata.client_id_metadata_document_supported, [true, false], 'client_id_metadata_document_supported');
|
|
111
145
|
return metadata;
|
|
112
146
|
}
|
|
147
|
+
export function signInMethods(metadata) {
|
|
148
|
+
const auth = metadata.authorization, methods = [];
|
|
149
|
+
if (!auth)
|
|
150
|
+
return methods;
|
|
151
|
+
if (auth.grant_types_supported.includes('authorization_code') && auth.authorization_endpoint)
|
|
152
|
+
methods.push({ method: 'direct', scopes: [...auth.scopes_supported ?? []] });
|
|
153
|
+
if (auth.grant_types_supported.includes('urn:ietf:params:oauth:grant-type:device_code') && auth.device_authorization_endpoint)
|
|
154
|
+
methods.push({ method: 'device', scopes: [...auth.scopes_supported ?? []] });
|
|
155
|
+
if (metadata.document.auth?.mediated)
|
|
156
|
+
methods.push({ method: 'mediated', scopes: [...metadata.document.auth.mediated.scopes] });
|
|
157
|
+
return methods;
|
|
158
|
+
}
|
|
113
159
|
export function parseClientMetadata(value, clientId) {
|
|
114
160
|
const parsed = validateClientId(clientId);
|
|
115
161
|
const metadata = object(value);
|
|
@@ -118,9 +164,8 @@ export function parseClientMetadata(value, clientId) {
|
|
|
118
164
|
string(metadata.client_name, 'client_name');
|
|
119
165
|
member(metadata.token_endpoint_auth_method, ['private_key_jwt'], 'token_endpoint_auth_method');
|
|
120
166
|
const redirects = strings(metadata.redirect_uris, 'redirect_uris', true);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
throw new ValidationError('client_endpoint_domain');
|
|
167
|
+
if (secureUrl(url(metadata.jwks_uri, 'jwks_uri')).hostname !== parsed.hostname || redirects.some(endpoint => !validRedirect(endpoint, clientId)))
|
|
168
|
+
throw new ValidationError('client_endpoint_domain');
|
|
124
169
|
if ('logo_uri' in metadata)
|
|
125
170
|
url(metadata.logo_uri, 'logo_uri');
|
|
126
171
|
return metadata;
|
|
@@ -189,13 +234,15 @@ export function createMetadataResolver(transport, options = {}) {
|
|
|
189
234
|
catch {
|
|
190
235
|
throw new ValidationError('metadata.json');
|
|
191
236
|
}
|
|
192
|
-
const servedUrl = response.status === 304 ? cached.url :
|
|
237
|
+
const servedUrl = response.url || (response.status === 304 ? cached.url : target);
|
|
193
238
|
validate(value, servedUrl);
|
|
239
|
+
if (response.status === 304)
|
|
240
|
+
validate(value, cached.url);
|
|
194
241
|
const control = response.headers.get('cache-control') ?? (response.status === 304 ? cached?.control : undefined) ?? '';
|
|
195
242
|
const age = Number(response.headers.get('age') ?? 0);
|
|
196
243
|
const seconds = Number(/(?:^|,)\s*max-age=(\d+)/i.exec(control)?.[1] ?? 60); // no max-age: 60 s floor (guide: at least a minute)
|
|
197
244
|
const expires = now() + (/\b(?:no-store|no-cache)\b/i.test(control) ? 0 : Math.max(0, Math.min(300, seconds - (Number.isFinite(age) ? age : 0)))) * 1000;
|
|
198
|
-
const result = { value, control, url:
|
|
245
|
+
const result = { value, control, url: servedUrl, expires, etag: response.headers.get('etag') ?? cached?.etag, modified: response.headers.get('last-modified') ?? cached?.modified };
|
|
199
246
|
if (startedEpoch !== epoch)
|
|
200
247
|
return result;
|
|
201
248
|
if (!/\bno-store\b/i.test(control))
|
|
@@ -250,6 +297,12 @@ export function createMetadataResolver(transport, options = {}) {
|
|
|
250
297
|
remember(authDocuments, document.auth.issuer, entries, maxEntries, inner => [...inner.values()].every(expiredEntry));
|
|
251
298
|
return { document, authorization: parseAuthorizationMetadata(auth.value, document), url: found.url };
|
|
252
299
|
},
|
|
300
|
+
async resolveExcludedTasks(document) {
|
|
301
|
+
if (!document.excluded_tasks)
|
|
302
|
+
return [];
|
|
303
|
+
const found = await get(document.excluded_tasks.url, value => parseExcludedTasks(value, document), 'discovery');
|
|
304
|
+
return parseExcludedTasks(found.value, document);
|
|
305
|
+
},
|
|
253
306
|
async resolveClient(clientId) { validateClientId(clientId); const found = await get(clientId, value => parseClientMetadata(value, clientId), 'endpoint'); return parseClientMetadata(found.value, clientId); },
|
|
254
307
|
async resolveJwks(uri, settings = {}) {
|
|
255
308
|
const validate = (value) => {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { secureUrl } from './transport.js';
|
|
2
|
+
// RFC 8252 loopback callbacks are a return address, never a metadata/fetch exception.
|
|
3
|
+
export function loopbackRedirect(value) {
|
|
4
|
+
if (!/^http:\/\/(127\.0\.0\.1|\[::1\])(?::[0-9]+)?\//.test(value) || /[\\\s\x00-\x1f]/.test(value))
|
|
5
|
+
return false;
|
|
6
|
+
try {
|
|
7
|
+
const url = new URL(value);
|
|
8
|
+
return !url.username && !url.password && !url.hash;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function validRedirect(value, clientId) {
|
|
15
|
+
if (/[\\\s\x00-\x1f]/.test(value))
|
|
16
|
+
return false;
|
|
17
|
+
if (loopbackRedirect(value))
|
|
18
|
+
return true;
|
|
19
|
+
try {
|
|
20
|
+
return secureUrl(value).hostname === secureUrl(clientId).hostname;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function registeredRedirect(value, registered, clientId) {
|
|
27
|
+
if (!validRedirect(value, clientId))
|
|
28
|
+
return false;
|
|
29
|
+
return registered.some(entry => {
|
|
30
|
+
if (entry === value)
|
|
31
|
+
return true;
|
|
32
|
+
if (!loopbackRedirect(entry) || !loopbackRedirect(value))
|
|
33
|
+
return false;
|
|
34
|
+
// Compare the original URI strings: only the literal port may differ.
|
|
35
|
+
const withoutPort = (uri) => uri.replace(/^(http:\/\/(?:127\.0\.0\.1|\[::1\]))(?::[0-9]+)?(?=\/)/, '$1');
|
|
36
|
+
return withoutPort(entry) === withoutPort(value);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { PoppyDocument } from './discovery.js';
|
|
2
|
+
export type AlternativeReference = {
|
|
3
|
+
type: 'web';
|
|
4
|
+
url: string;
|
|
5
|
+
} | {
|
|
6
|
+
type: 'openapi';
|
|
7
|
+
url: string;
|
|
8
|
+
operation_id: string;
|
|
9
|
+
} | {
|
|
10
|
+
type: 'mcp';
|
|
11
|
+
url: string;
|
|
12
|
+
tool: string;
|
|
13
|
+
};
|
|
14
|
+
export interface Alternative {
|
|
15
|
+
instructions?: string;
|
|
16
|
+
references?: AlternativeReference[];
|
|
17
|
+
}
|
|
18
|
+
export interface ExcludedTask {
|
|
19
|
+
task: string;
|
|
20
|
+
alternative?: Alternative;
|
|
21
|
+
}
|
|
22
|
+
export declare function parseAlternative(value: unknown, document: PoppyDocument): Alternative;
|
|
23
|
+
export declare function parseExcludedTasks(value: unknown, document: PoppyDocument): ExcludedTask[];
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { object, string, ValidationError } from '../protocol/wire.js';
|
|
2
|
+
import { secureUrl } from './transport.js';
|
|
3
|
+
function text(value, field, max) {
|
|
4
|
+
const result = string(value, field);
|
|
5
|
+
if ([...result].length > max)
|
|
6
|
+
throw new ValidationError(field);
|
|
7
|
+
return result;
|
|
8
|
+
}
|
|
9
|
+
export function parseAlternative(value, document) {
|
|
10
|
+
const item = object(value, 'alternative'), result = {};
|
|
11
|
+
if (!('instructions' in item) && !('references' in item))
|
|
12
|
+
throw new ValidationError('alternative');
|
|
13
|
+
if ('instructions' in item)
|
|
14
|
+
result.instructions = text(item.instructions, 'instructions', 2000);
|
|
15
|
+
if ('references' in item) {
|
|
16
|
+
if (!Array.isArray(item.references))
|
|
17
|
+
throw new ValidationError('references');
|
|
18
|
+
result.references = [];
|
|
19
|
+
for (const reference of item.references) {
|
|
20
|
+
const ref = object(reference, 'reference'), type = string(ref.type, 'reference.type');
|
|
21
|
+
if (!['web', 'openapi', 'mcp'].includes(type))
|
|
22
|
+
continue;
|
|
23
|
+
const url = string(ref.url, 'reference.url');
|
|
24
|
+
if (type !== 'web' && !document.apis?.some(api => api.type === type && api.url === url))
|
|
25
|
+
continue;
|
|
26
|
+
secureUrl(url);
|
|
27
|
+
if (type === 'web')
|
|
28
|
+
result.references.push({ type, url });
|
|
29
|
+
else {
|
|
30
|
+
const field = type === 'openapi' ? 'operation_id' : 'tool', name = string(ref[field], field);
|
|
31
|
+
result.references.push(type === 'openapi' ? { type, url, operation_id: name } : { type: 'mcp', url, tool: name });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
export function parseExcludedTasks(value, document) {
|
|
38
|
+
const tasks = object(value, 'excluded_tasks').tasks;
|
|
39
|
+
if (!Array.isArray(tasks))
|
|
40
|
+
throw new ValidationError('tasks');
|
|
41
|
+
return tasks.map(value => {
|
|
42
|
+
const item = object(value, 'task');
|
|
43
|
+
return { task: text(item.task, 'task', 200), ...('alternative' in item ? { alternative: parseAlternative(item.alternative, document) } : {}) };
|
|
44
|
+
});
|
|
45
|
+
}
|
package/dist/postgres.js
CHANGED
|
@@ -1,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 {
|
package/dist/protocol/wire.d.ts
CHANGED
|
@@ -4,11 +4,18 @@ export type Json = null | boolean | number | string | Json[] | {
|
|
|
4
4
|
export type JsonObject = {
|
|
5
5
|
[key: string]: Json;
|
|
6
6
|
};
|
|
7
|
-
export type
|
|
7
|
+
export type AuthorType = 'agent' | 'human';
|
|
8
|
+
export interface Author {
|
|
9
|
+
type: AuthorType;
|
|
10
|
+
id?: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
image?: string;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
8
15
|
export type Status = 'working' | 'idle' | 'queued' | 'closed';
|
|
9
16
|
export interface Message {
|
|
10
17
|
id: string;
|
|
11
|
-
|
|
18
|
+
author: Author;
|
|
12
19
|
text?: string;
|
|
13
20
|
data?: JsonObject;
|
|
14
21
|
context?: JsonObject;
|
|
@@ -25,7 +32,7 @@ export interface ConversationRequest {
|
|
|
25
32
|
export interface ConversationState {
|
|
26
33
|
conversation_id: string;
|
|
27
34
|
status: Status;
|
|
28
|
-
responder:
|
|
35
|
+
responder: AuthorType;
|
|
29
36
|
[key: string]: unknown;
|
|
30
37
|
}
|
|
31
38
|
export interface ConversationEvent {
|
|
@@ -40,11 +47,7 @@ export type KnownEvent = (ConversationEvent & {
|
|
|
40
47
|
}) | (ConversationEvent & {
|
|
41
48
|
type: 'state';
|
|
42
49
|
status: Status;
|
|
43
|
-
responder:
|
|
44
|
-
}) | (ConversationEvent & {
|
|
45
|
-
type: 'authorization';
|
|
46
|
-
error: 'sign_in_required';
|
|
47
|
-
scope?: string;
|
|
50
|
+
responder: AuthorType;
|
|
48
51
|
}) | (ConversationEvent & {
|
|
49
52
|
type: 'authorization';
|
|
50
53
|
error: 'insufficient_scope';
|
|
@@ -65,9 +68,7 @@ export interface TokenResponse {
|
|
|
65
68
|
access_token: string;
|
|
66
69
|
token_type: 'DPoP' | 'Bearer';
|
|
67
70
|
expires_in: number;
|
|
68
|
-
scope
|
|
69
|
-
session_id: string;
|
|
70
|
-
signed_in: boolean;
|
|
71
|
+
scope?: string;
|
|
71
72
|
refresh_token?: string;
|
|
72
73
|
refresh_token_expires_in?: number;
|
|
73
74
|
[key: string]: unknown;
|
package/dist/protocol/wire.js
CHANGED
|
@@ -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
|
-
|
|
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, ['
|
|
130
|
-
if (event.
|
|
131
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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');
|
package/dist/provider.d.ts
CHANGED
|
@@ -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
|
-
|
|
133
|
+
sessionKey: string;
|
|
141
134
|
state: string;
|
|
142
135
|
}) => Promise<Row>;
|
|
143
136
|
takeResult: (input: {
|
|
144
137
|
operationId: string;
|
|
145
138
|
issuer: string;
|
|
146
|
-
|
|
139
|
+
sessionKey: string;
|
|
147
140
|
}) => Promise<Row>;
|
|
148
141
|
cancel: (operationId: string) => Promise<Row>;
|
|
149
142
|
revokeEnrollment: () => Promise<Row>;
|