@poppykit/server 0.4.0 → 0.5.1
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 +10 -3
- package/AUTH.md +120 -124
- package/CHANGELOG.md +20 -1
- package/CONFORMANCE.md +73 -0
- package/CONTRIBUTING.md +12 -11
- package/DELIVERY.md +2 -0
- package/INTEGRATIONS.md +74 -81
- package/OPERATIONS.md +142 -0
- package/README.md +11 -20
- package/RELEASING.md +2 -0
- package/SECURITY.md +2 -0
- package/STORAGE.md +62 -17
- package/dist/adapter-contract.d.ts +18 -0
- package/dist/adapter-contract.js +79 -0
- package/dist/auth/authenticator.d.ts +3 -1
- package/dist/auth/authenticator.js +34 -29
- package/dist/auth/authority.d.ts +8 -2
- package/dist/auth/authority.js +7 -2
- package/dist/auth/session.d.ts +47 -0
- package/dist/auth/session.js +94 -0
- package/dist/browser.d.ts +15 -16
- package/dist/browser.js +65 -33
- package/dist/conversation/service.d.ts +11 -1
- package/dist/conversation/service.js +39 -9
- package/dist/feedback.d.ts +1 -1
- package/dist/feedback.js +5 -5
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -0
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +8 -4
- package/dist/network/discovery.d.ts +31 -13
- package/dist/network/discovery.js +103 -66
- package/dist/operations.d.ts +115 -0
- package/dist/operations.js +212 -0
- package/dist/postgres-runtime.js +12 -4
- package/dist/postgres.d.ts +6 -0
- package/dist/postgres.js +72 -4
- package/dist/protocol/operations.d.ts +51 -0
- package/dist/protocol/operations.js +98 -0
- package/dist/protocol/session.d.ts +28 -0
- package/dist/protocol/session.js +93 -0
- package/dist/protocol/sse.d.ts +1 -0
- package/dist/protocol/sse.js +1 -1
- package/dist/protocol/wire.d.ts +8 -4
- package/dist/protocol/wire.js +37 -15
- package/dist/reference-auth/index.d.ts +10 -16
- package/dist/reference-auth/index.js +225 -430
- package/dist/sqlite.d.ts +11 -0
- package/dist/sqlite.js +84 -4
- package/examples/operations.mjs +18 -0
- package/examples/quickstart.mjs +8 -5
- package/migrations/RETIREMENT.md +77 -0
- package/package.json +5 -2
package/dist/adapter-contract.js
CHANGED
|
@@ -23,6 +23,63 @@ export function runIssuerAdapterContract(fixture) {
|
|
|
23
23
|
}
|
|
24
24
|
});
|
|
25
25
|
}
|
|
26
|
+
export function runSessionAdapterContract(fixture) {
|
|
27
|
+
test('Session adapter contract: admission counts, concurrent writers, detached snapshots and rollback', async () => {
|
|
28
|
+
const f = await fixture(), at = 1790900000000, client = 'https://agent.example/client', sid = 'session_a';
|
|
29
|
+
try {
|
|
30
|
+
const empty = await f.store.read(client, sid);
|
|
31
|
+
assert.equal(empty.record, null);
|
|
32
|
+
assert.equal((await f.second.read(client, sid)).generation, empty.generation);
|
|
33
|
+
await Promise.all(Array.from({ length: 8 }, (_, i) => (i % 2 ? f.store : f.second).transaction(client, sid, at, state => {
|
|
34
|
+
assert.equal(state.activeCount, state.record ? 1 : 0);
|
|
35
|
+
state.record ??= { instanceId: 'instance_a', clientId: client, sid, userId: 'user_a', createdAt: at, expiresAt: at + 1000, ended: false };
|
|
36
|
+
state.record.expiresAt++;
|
|
37
|
+
})));
|
|
38
|
+
const snapshot = await f.second.read(client, sid);
|
|
39
|
+
assert.equal(snapshot.record.expiresAt, at + 1008);
|
|
40
|
+
snapshot.record.userId = 'detached';
|
|
41
|
+
assert.equal((await f.store.read(client, sid)).record.userId, 'user_a');
|
|
42
|
+
await assert.rejects(f.store.transaction(client, sid, at, state => { state.record.userId = 'rollback'; throw new Error('rollback'); }), /rollback/);
|
|
43
|
+
await assert.rejects(f.store.transaction(client, sid, at, async (state) => { state.record.userId = 'async'; }));
|
|
44
|
+
assert.equal((await f.second.read(client, sid)).record.userId, 'user_a');
|
|
45
|
+
await f.second.transaction(client, 'absent', at, state => { assert.equal(state.activeCount, 1); assert.equal(state.earliestExpiry, at + 1008); });
|
|
46
|
+
await f.store.transaction(client, 'absent', at + 1008, state => { assert.equal(state.activeCount, 0); assert.equal(state.earliestExpiry, null); });
|
|
47
|
+
assert.equal((await f.store.read('https://other.example/client', sid)).record, null);
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await f.close();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
export function runBrowserCookieAdapterContract(fixture) {
|
|
55
|
+
test('browser adapter contract: one authorization and consumption across handles, fixed detached cookies', async () => {
|
|
56
|
+
const f = await fixture(), at = 1790900000000, principal = { issuer: 'https://company.example', clientId: 'https://agent.example/client', userId: 'user_a', sessionId: 'session_a', sessionVersion: 1, scopes: ['read'], tokenExpiresAt: at + 1000 };
|
|
57
|
+
try {
|
|
58
|
+
await f.store.putJoin('code', { cookieHash: 'cookie', expiresAt: at + 1000 });
|
|
59
|
+
const authorized = await Promise.all([f.store.authorizeJoin('code', principal, at), f.second.authorizeJoin('code', principal, at)]);
|
|
60
|
+
assert.deepEqual(authorized.sort(), [false, true]);
|
|
61
|
+
const consumed = await Promise.all([f.store.consumeJoin('code'), f.second.consumeJoin('code')]);
|
|
62
|
+
assert.equal(consumed.filter(Boolean).length, 1);
|
|
63
|
+
assert.deepEqual(consumed.find(Boolean).principal, principal);
|
|
64
|
+
await f.store.putJoin('expired', { cookieHash: 'cookie', expiresAt: at - 1 });
|
|
65
|
+
assert.equal(await f.second.authorizeJoin('expired', principal, at), false);
|
|
66
|
+
assert.ok(await f.second.consumeJoin('expired'));
|
|
67
|
+
assert.equal(await f.store.consumeJoin('expired'), null);
|
|
68
|
+
const binding = { clientId: principal.clientId, userId: principal.userId, sessionId: principal.sessionId, principal, expiresAt: at + 1000 };
|
|
69
|
+
await f.store.put('browser', binding);
|
|
70
|
+
principal.scopes.push('write');
|
|
71
|
+
const cookie = await f.second.get('browser');
|
|
72
|
+
assert.deepEqual(cookie.principal.scopes, ['read']);
|
|
73
|
+
cookie.principal.scopes.push('admin');
|
|
74
|
+
assert.deepEqual((await f.store.get('browser')).principal.scopes, ['read']);
|
|
75
|
+
await f.second.remove('browser');
|
|
76
|
+
assert.equal(await f.store.get('browser'), null);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
await f.close();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
26
83
|
const endpoint = 'https://contract.example.test/poppy/conversations';
|
|
27
84
|
const base = { issuer: 'https://auth.contract.test', clientId: 'https://agent.contract.test/client', sessionId: 's1', userId: 'u_1', scopes: [], tokenExpiresAt: 9e12, sessionVersion: 1 };
|
|
28
85
|
const principals = {
|
|
@@ -195,3 +252,25 @@ export function runConversationAdapterContract(fixture) {
|
|
|
195
252
|
assert.equal((await call(a, `/${id}/events`, 'signedOut')).status, 404);
|
|
196
253
|
}));
|
|
197
254
|
}
|
|
255
|
+
export function runBusinessOperationAdapterContract(fixture) {
|
|
256
|
+
test('business operation adapter contract: absent-row races, rollback, detached snapshots and version fences', async () => {
|
|
257
|
+
const f = await fixture();
|
|
258
|
+
try {
|
|
259
|
+
await Promise.all(Array.from({ length: 10 }, (_, n) => (n % 2 ? f.store : f.second).transaction('action', async (slot) => {
|
|
260
|
+
slot.record ??= { version: 1, id: 'action', owner: { companyId: 'https://company.example', clientId: 'https://agent.example/client', userId: 'user', sessionId: 'sid', sessionInstanceId: 'instance', sessionGeneration: 'generation' }, scopes: [], revisions: {} };
|
|
261
|
+
slot.record.scopes.push(String(n));
|
|
262
|
+
})));
|
|
263
|
+
const read = () => f.store.transaction('action', async (slot) => slot.record);
|
|
264
|
+
const record = await read();
|
|
265
|
+
assert.equal(record.scopes.length, 10);
|
|
266
|
+
record.scopes.length = 0;
|
|
267
|
+
await assert.rejects(f.second.transaction('action', async (slot) => { slot.record.scopes = []; throw Error('rollback'); }), /rollback/);
|
|
268
|
+
assert.equal((await read()).scopes.length, 10);
|
|
269
|
+
await assert.rejects(f.store.transaction('action', async (slot) => { Object.assign(slot.record, { version: 99 }); }), /binding|version/);
|
|
270
|
+
assert.equal((await read()).version, 1);
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
await f.close();
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { ExternalAuthorityAdapter, RequestAuthenticator, ResourceEndpoint } from './authority.js';
|
|
2
|
+
import type { SessionAuthority } from './session.js';
|
|
2
3
|
export declare function createRequestAuthenticator(options: {
|
|
3
|
-
authority
|
|
4
|
+
authority?: Pick<ExternalAuthorityAdapter, 'introspect'>;
|
|
5
|
+
sessions?: SessionAuthority;
|
|
4
6
|
endpoints: ResourceEndpoint[];
|
|
5
7
|
now: () => number;
|
|
6
8
|
}): RequestAuthenticator;
|
|
@@ -1,51 +1,56 @@
|
|
|
1
1
|
import { secureUrl } from '../network/transport.js';
|
|
2
2
|
import { AuthorityError } from './authority.js';
|
|
3
3
|
import { verifyDpopProof } from './dpop.js';
|
|
4
|
-
const DPOP_AUTHORIZATION = /^DPoP ([A-Za-z0-9\-._~+/]+=*)$/i;
|
|
5
|
-
// Only introspect is used here; the policy half of the adapter is consumed by the conversation service.
|
|
6
4
|
export function createRequestAuthenticator(options) {
|
|
7
5
|
for (const endpoint of options.endpoints) {
|
|
8
6
|
if (secureUrl(endpoint.origin).origin !== endpoint.origin || !endpoint.pathPrefix.startsWith('/'))
|
|
9
7
|
throw new TypeError('Invalid resource endpoint');
|
|
10
|
-
if (endpoint.mcp
|
|
11
|
-
|
|
8
|
+
if (endpoint.mcp) {
|
|
9
|
+
const resource = secureUrl(endpoint.resource ?? '');
|
|
10
|
+
if (resource.origin !== endpoint.origin || resource.pathname !== endpoint.pathPrefix || secureUrl(endpoint.mcp.metadataUrl).href !== endpoint.mcp.metadataUrl)
|
|
11
|
+
throw new TypeError('Invalid MCP resource');
|
|
12
|
+
}
|
|
12
13
|
}
|
|
13
14
|
return {
|
|
14
15
|
async authenticate(request) {
|
|
15
|
-
// The Request URL must already be the trusted public URL; forwarding headers are never read.
|
|
16
16
|
const url = new URL(request.url);
|
|
17
17
|
const endpoint = options.endpoints
|
|
18
18
|
.filter(item => url.origin === item.origin && (url.pathname === item.pathPrefix || (!item.mcp && url.pathname.startsWith(item.pathPrefix.endsWith('/') ? item.pathPrefix : `${item.pathPrefix}/`))))
|
|
19
19
|
.sort((a, b) => b.pathPrefix.length - a.pathPrefix.length)[0];
|
|
20
|
-
if (!endpoint)
|
|
20
|
+
if (!endpoint || (endpoint.mcp && request.url !== endpoint.resource))
|
|
21
|
+
throw new AuthorityError('invalid_token');
|
|
22
|
+
const authorization = request.headers.get('authorization');
|
|
23
|
+
if (authorization === null) {
|
|
24
|
+
if (!options.sessions)
|
|
25
|
+
throw new AuthorityError('invalid_session');
|
|
26
|
+
return options.sessions.authenticate(request);
|
|
27
|
+
}
|
|
28
|
+
const credentials = /^(Bearer|DPoP) ([A-Za-z0-9\-._~+/]+=*)$/i.exec(authorization);
|
|
29
|
+
if (!credentials || credentials[2].length > 4096 || !options.authority)
|
|
21
30
|
throw new AuthorityError('invalid_token');
|
|
22
|
-
|
|
31
|
+
const token = credentials[2], scheme = credentials[1].toLowerCase();
|
|
32
|
+
const { principal, dpopJkt } = await options.authority.introspect(token, endpoint.resource);
|
|
33
|
+
if (principal.tokenExpiresAt <= options.now())
|
|
23
34
|
throw new AuthorityError('invalid_token');
|
|
24
|
-
if (
|
|
25
|
-
|
|
26
|
-
if (!bearer || bearer.length > 4096)
|
|
35
|
+
if (scheme === 'bearer') {
|
|
36
|
+
if (dpopJkt !== null)
|
|
27
37
|
throw new AuthorityError('invalid_token');
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
if (endpoint.mcp && !endpoint.mcp.dpop)
|
|
31
41
|
throw new AuthorityError('invalid_token');
|
|
32
|
-
|
|
42
|
+
const proof = await verifyDpopProof(request.headers.get('dpop'), { method: request.method, url: `${url.origin}${url.pathname}`, nowMs: options.now(), accessToken: token });
|
|
43
|
+
if (dpopJkt !== proof.jkt)
|
|
44
|
+
throw new AuthorityError('invalid_dpop_proof');
|
|
45
|
+
}
|
|
46
|
+
if (endpoint.mcp && !request.headers.has('poppy-session')) {
|
|
47
|
+
if (!principal.accountId)
|
|
33
48
|
throw new AuthorityError('invalid_token');
|
|
34
|
-
return principal;
|
|
49
|
+
return { ...principal, userId: '', sessionId: '', sessionVersion: 0 };
|
|
35
50
|
}
|
|
36
|
-
if (
|
|
37
|
-
throw new AuthorityError('
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
throw new AuthorityError('invalid_token');
|
|
41
|
-
const nowMs = options.now();
|
|
42
|
-
const proof = await verifyDpopProof(request.headers.get('dpop'), { method: request.method, url: `${url.origin}${url.pathname}`, nowMs, accessToken: token });
|
|
43
|
-
const { principal, dpopJkt } = await options.authority.introspect(token, endpoint.resource);
|
|
44
|
-
if (principal.tokenExpiresAt <= nowMs)
|
|
45
|
-
throw new AuthorityError('invalid_token');
|
|
46
|
-
if (dpopJkt !== proof.jkt)
|
|
47
|
-
throw new AuthorityError('invalid_dpop_proof');
|
|
48
|
-
return principal;
|
|
49
|
-
}
|
|
51
|
+
if (!options.sessions)
|
|
52
|
+
throw new AuthorityError('invalid_session');
|
|
53
|
+
return options.sessions.authenticate(request, { token, principal });
|
|
54
|
+
},
|
|
50
55
|
};
|
|
51
56
|
}
|
package/dist/auth/authority.d.ts
CHANGED
|
@@ -9,6 +9,10 @@ export interface Principal {
|
|
|
9
9
|
tokenExpiresAt: number;
|
|
10
10
|
sessionVersion: number;
|
|
11
11
|
issuerGeneration?: string;
|
|
12
|
+
agentId?: string;
|
|
13
|
+
sessionGeneration?: string;
|
|
14
|
+
sessionInstanceId?: string;
|
|
15
|
+
sessionExpiresAt?: number;
|
|
12
16
|
}
|
|
13
17
|
export type WorkRevocationPolicy = 'stop-account-work' | 'allow-accepted-work-to-finish';
|
|
14
18
|
export interface AcceptedAuthority {
|
|
@@ -44,12 +48,14 @@ export interface ExternalAuthorityAdapter {
|
|
|
44
48
|
}>;
|
|
45
49
|
policy: AuthorityPolicy;
|
|
46
50
|
}
|
|
47
|
-
export type AuthorityErrorCode = 'invalid_token' | 'invalid_dpop_proof' | 'use_dpop_nonce' | '
|
|
51
|
+
export type AuthorityErrorCode = 'invalid_token' | 'invalid_dpop_proof' | 'use_dpop_nonce' | 'invalid_session' | 'account_required' | 'rate_limited' | 'insufficient_scope';
|
|
48
52
|
export declare class AuthorityError extends Error {
|
|
49
53
|
readonly code: AuthorityErrorCode;
|
|
50
|
-
readonly status: 401 | 403;
|
|
54
|
+
readonly status: 400 | 401 | 403 | 429;
|
|
51
55
|
readonly headers: Headers;
|
|
52
56
|
constructor(code: AuthorityErrorCode, options?: {
|
|
53
57
|
scope?: string;
|
|
58
|
+
retryAfter?: number;
|
|
54
59
|
});
|
|
60
|
+
response(): Response;
|
|
55
61
|
}
|
package/dist/auth/authority.js
CHANGED
|
@@ -7,10 +7,15 @@ export class AuthorityError extends Error {
|
|
|
7
7
|
super(`Authorization failed: ${code}`);
|
|
8
8
|
this.code = code;
|
|
9
9
|
this.name = 'AuthorityError';
|
|
10
|
-
this.status = code === '
|
|
10
|
+
this.status = code === 'rate_limited' ? 429 : code === 'invalid_session' ? 400 : code === 'insufficient_scope' ? 403 : 401;
|
|
11
11
|
if (options.scope !== undefined && !/^[\x21\x23-\x5B\x5D-\x7E ]*$/.test(options.scope))
|
|
12
12
|
throw new TypeError('scope');
|
|
13
13
|
const scope = options.scope === undefined ? '' : `, scope="${options.scope}"`;
|
|
14
|
-
|
|
14
|
+
if (options.retryAfter !== undefined && (!Number.isSafeInteger(options.retryAfter) || options.retryAfter < 1))
|
|
15
|
+
throw new TypeError('retryAfter');
|
|
16
|
+
this.headers = code === 'rate_limited' ? new Headers({ 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Retry-After': String(options.retryAfter ?? 60) }) : code === 'invalid_session' ? new Headers({ 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }) : new Headers({ 'WWW-Authenticate': code === 'account_required' ? `Bearer${scope ? ' ' + scope.slice(2) : ''}, DPoP${scope ? ' ' + scope.slice(2) : ''}` : `DPoP error="${code}"${scope}, algs="ES256", Bearer error="${code}"${scope}` });
|
|
17
|
+
}
|
|
18
|
+
response() {
|
|
19
|
+
return new Response(this.code === 'invalid_session' || this.code === 'rate_limited' ? JSON.stringify({ error: this.code }) : null, { status: this.status, headers: this.headers });
|
|
15
20
|
}
|
|
16
21
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { AuthorityCoordinator, AuthorityPolicy, Principal } from './authority.js';
|
|
2
|
+
import type { MetadataResolver } from '../network/discovery.js';
|
|
3
|
+
export interface SessionRecord {
|
|
4
|
+
instanceId: string;
|
|
5
|
+
clientId: string;
|
|
6
|
+
sid: string;
|
|
7
|
+
userId: string;
|
|
8
|
+
agentId?: string;
|
|
9
|
+
createdAt: number;
|
|
10
|
+
expiresAt: number;
|
|
11
|
+
ended: boolean;
|
|
12
|
+
accountId?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface SessionState {
|
|
15
|
+
version: 1;
|
|
16
|
+
generation: string;
|
|
17
|
+
record: SessionRecord | null;
|
|
18
|
+
}
|
|
19
|
+
export interface SessionAdmission extends SessionState {
|
|
20
|
+
activeCount: number;
|
|
21
|
+
earliestExpiry: number | null;
|
|
22
|
+
}
|
|
23
|
+
/** Serialize admission per client across processes; callbacks are synchronous, detached, and atomic. */
|
|
24
|
+
export interface SessionStore {
|
|
25
|
+
read(clientId: string, sid: string): Promise<SessionState>;
|
|
26
|
+
transaction<T>(clientId: string, sid: string, at: number, change: (state: SessionAdmission) => T): Promise<T>;
|
|
27
|
+
}
|
|
28
|
+
export interface SessionAuthority {
|
|
29
|
+
authenticate(request: Request, account?: {
|
|
30
|
+
token: string;
|
|
31
|
+
principal: Principal;
|
|
32
|
+
}): Promise<Principal>;
|
|
33
|
+
end(clientId: string, sid: string): Promise<void>;
|
|
34
|
+
policy: AuthorityPolicy;
|
|
35
|
+
}
|
|
36
|
+
/** Resources own Sessions; an OAuth issuer supplies only account authority. */
|
|
37
|
+
export declare function createSessionAuthority(options: {
|
|
38
|
+
companyId: string;
|
|
39
|
+
metadata: MetadataResolver;
|
|
40
|
+
store: SessionStore;
|
|
41
|
+
coordinator: AuthorityCoordinator;
|
|
42
|
+
now: () => number;
|
|
43
|
+
accountPolicy?: AuthorityPolicy;
|
|
44
|
+
lifetimeSeconds?: number;
|
|
45
|
+
maxSessionsPerClient?: number;
|
|
46
|
+
acceptClient?: (clientId: string) => Promise<boolean>;
|
|
47
|
+
}): SessionAuthority;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { AuthorityError } from './authority.js';
|
|
3
|
+
import { verifySessionJwt } from '../protocol/session.js';
|
|
4
|
+
import { secureUrl } from '../network/transport.js';
|
|
5
|
+
/** Resources own Sessions; an OAuth issuer supplies only account authority. */
|
|
6
|
+
export function createSessionAuthority(options) {
|
|
7
|
+
secureUrl(options.companyId);
|
|
8
|
+
const lifetime = options.lifetimeSeconds ?? 86400;
|
|
9
|
+
if (!Number.isSafeInteger(lifetime) || lifetime < 1 || lifetime > 31_536_000)
|
|
10
|
+
throw new TypeError('Session lifetime');
|
|
11
|
+
const maxSessions = options.maxSessionsPerClient ?? 10000;
|
|
12
|
+
if (!Number.isSafeInteger(maxSessions) || maxSessions < 1)
|
|
13
|
+
throw new TypeError('maxSessionsPerClient');
|
|
14
|
+
const invalid = () => new AuthorityError('invalid_session');
|
|
15
|
+
async function current(principal) {
|
|
16
|
+
const state = await options.store.read(principal.clientId, principal.sessionId), record = state.record;
|
|
17
|
+
if (principal.issuer !== options.companyId || principal.sessionGeneration !== state.generation || !record || record.ended || record.expiresAt <= options.now()
|
|
18
|
+
|| record.clientId !== principal.clientId || record.sid !== principal.sessionId || record.userId !== principal.userId || record.instanceId !== principal.sessionInstanceId || record.agentId !== principal.agentId)
|
|
19
|
+
throw invalid();
|
|
20
|
+
}
|
|
21
|
+
const policy = {
|
|
22
|
+
withCurrent: (principal, scopes, action) => options.coordinator.run(async () => {
|
|
23
|
+
await current(principal);
|
|
24
|
+
if (principal.tokenExpiresAt <= options.now())
|
|
25
|
+
throw new AuthorityError('invalid_token');
|
|
26
|
+
if (!scopes.every(scope => principal.scopes.includes(scope)))
|
|
27
|
+
throw new AuthorityError(principal.accountId ? 'insufficient_scope' : 'account_required', { scope: scopes.join(' ') });
|
|
28
|
+
if (principal.accountId) {
|
|
29
|
+
if (!options.accountPolicy)
|
|
30
|
+
throw new AuthorityError('invalid_token');
|
|
31
|
+
return options.accountPolicy.withCurrent(principal, scopes, action);
|
|
32
|
+
}
|
|
33
|
+
return action();
|
|
34
|
+
}),
|
|
35
|
+
withAccepted: (accepted, scopes, action) => options.coordinator.run(async () => {
|
|
36
|
+
const state = await options.store.read(accepted.principal.clientId, accepted.principal.sessionId);
|
|
37
|
+
if (accepted.principal.issuer !== options.companyId || state.generation !== accepted.principal.sessionGeneration)
|
|
38
|
+
throw invalid();
|
|
39
|
+
if (!scopes.every(scope => accepted.principal.scopes.includes(scope)))
|
|
40
|
+
throw new AuthorityError('insufficient_scope', { scope: scopes.join(' ') });
|
|
41
|
+
if (accepted.principal.accountId) {
|
|
42
|
+
if (!options.accountPolicy)
|
|
43
|
+
throw new AuthorityError('invalid_token');
|
|
44
|
+
return options.accountPolicy.withAccepted(accepted, scopes, action);
|
|
45
|
+
}
|
|
46
|
+
return action();
|
|
47
|
+
}),
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
policy,
|
|
51
|
+
async authenticate(request, account) {
|
|
52
|
+
let claims;
|
|
53
|
+
try {
|
|
54
|
+
claims = await verifySessionJwt(request.headers.get('poppy-session'), {
|
|
55
|
+
companyId: options.companyId, method: request.method, url: request.url, nowMs: options.now(), metadata: options.metadata,
|
|
56
|
+
...(account ? { accessToken: account.token, tokenClientId: account.principal.clientId } : {}),
|
|
57
|
+
});
|
|
58
|
+
if (options.acceptClient && !await options.acceptClient(claims.iss))
|
|
59
|
+
throw invalid();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw invalid();
|
|
63
|
+
}
|
|
64
|
+
if (account && (account.principal.issuer !== options.companyId || account.principal.tokenExpiresAt <= options.now() || !account.principal.accountId))
|
|
65
|
+
throw new AuthorityError('invalid_token');
|
|
66
|
+
return options.coordinator.run(() => options.store.transaction(claims.iss, claims.sid, options.now(), state => {
|
|
67
|
+
const at = options.now();
|
|
68
|
+
if (claims.iat < at / 1000 - 60 || claims.delegationExpiresAt <= at)
|
|
69
|
+
throw invalid();
|
|
70
|
+
if (!Number.isSafeInteger(state.activeCount) || state.activeCount < 0)
|
|
71
|
+
throw new TypeError('Session admission count');
|
|
72
|
+
if (!state.record && state.activeCount >= maxSessions)
|
|
73
|
+
throw new AuthorityError('rate_limited', { retryAfter: Math.max(1, Math.ceil(((state.earliestExpiry ?? at + lifetime * 1000) - at) / 1000)) });
|
|
74
|
+
const record = state.record ??= { instanceId: randomUUID(), clientId: claims.iss, sid: claims.sid, userId: claims.sub,
|
|
75
|
+
...(claims.agent_id === undefined ? {} : { agentId: claims.agent_id }), createdAt: at, expiresAt: at + lifetime * 1000, ended: false };
|
|
76
|
+
if (record.clientId !== claims.iss || record.sid !== claims.sid || record.userId !== claims.sub || record.agentId !== claims.agent_id || record.ended || record.expiresAt <= at)
|
|
77
|
+
throw invalid();
|
|
78
|
+
record.instanceId ??= randomUUID();
|
|
79
|
+
if (account) {
|
|
80
|
+
if (record.accountId !== undefined && record.accountId !== account.principal.accountId)
|
|
81
|
+
throw invalid();
|
|
82
|
+
record.accountId = account.principal.accountId;
|
|
83
|
+
}
|
|
84
|
+
return { ...(account?.principal ?? { issuer: options.companyId, scopes: [], tokenExpiresAt: record.expiresAt }),
|
|
85
|
+
issuer: options.companyId, clientId: claims.iss, userId: claims.sub, sessionId: claims.sid,
|
|
86
|
+
...(claims.agent_id === undefined ? {} : { agentId: claims.agent_id }), sessionVersion: 1, sessionInstanceId: record.instanceId, sessionGeneration: state.generation, sessionExpiresAt: record.expiresAt };
|
|
87
|
+
}));
|
|
88
|
+
},
|
|
89
|
+
async end(clientId, sid) {
|
|
90
|
+
await options.coordinator.run(() => options.store.transaction(clientId, sid, options.now(), state => { if (state.record)
|
|
91
|
+
state.record.ended = true; }));
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
package/dist/browser.d.ts
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
import type { Principal, AuthorityPolicy } from './auth/authority.js';
|
|
1
|
+
import type { Principal, AuthorityPolicy, RequestAuthenticator } from './auth/authority.js';
|
|
2
2
|
export interface BrowserBinding {
|
|
3
3
|
clientId: string;
|
|
4
4
|
userId: string;
|
|
5
5
|
sessionId: string;
|
|
6
6
|
issuerGeneration?: string;
|
|
7
|
+
principal?: Principal;
|
|
7
8
|
}
|
|
8
|
-
export interface
|
|
9
|
-
|
|
9
|
+
export interface BrowserJoin {
|
|
10
|
+
cookieHash: string;
|
|
10
11
|
expiresAt: number;
|
|
11
|
-
|
|
12
|
-
export interface BrowserSessionAuthority {
|
|
13
|
-
join(sessionKey: string): Promise<BrowserSession>;
|
|
14
|
-
current(binding: BrowserBinding): Promise<BrowserSession>;
|
|
12
|
+
principal?: Principal;
|
|
15
13
|
}
|
|
16
14
|
export interface BrowserCookieStore {
|
|
17
15
|
put(hash: string, binding: BrowserBinding & {
|
|
@@ -20,28 +18,29 @@ export interface BrowserCookieStore {
|
|
|
20
18
|
get(hash: string): Promise<(BrowserBinding & {
|
|
21
19
|
expiresAt: number;
|
|
22
20
|
}) | null>;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
expiresAt: number;
|
|
30
|
-
} | null>;
|
|
21
|
+
remove(hash: string): Promise<void>;
|
|
22
|
+
putJoin(codeHash: string, binding: BrowserJoin): Promise<void>;
|
|
23
|
+
/** Atomically authorize an unexpired, never-authorized code. Do not consume it. */
|
|
24
|
+
authorizeJoin(codeHash: string, principal: Principal, at: number): Promise<boolean>;
|
|
25
|
+
/** Atomically remove and return, even when submission will fail. */
|
|
26
|
+
consumeJoin(codeHash: string): Promise<BrowserJoin | null>;
|
|
31
27
|
}
|
|
32
28
|
export declare function boundedText(request: Request, max: number): Promise<string>;
|
|
33
29
|
export declare function createBrowserSessions(options: {
|
|
34
30
|
endpoint: string;
|
|
35
31
|
domain: string;
|
|
36
|
-
|
|
32
|
+
authenticator: RequestAuthenticator;
|
|
37
33
|
cookies: BrowserCookieStore;
|
|
38
34
|
policy: AuthorityPolicy;
|
|
39
35
|
now: () => number;
|
|
36
|
+
/** Required for signed-in joins: clear the host's prior login without restoring it later. */
|
|
37
|
+
clearLogin?: (request: Request, headers: Headers) => Promise<void>;
|
|
40
38
|
cookieName?: string;
|
|
41
39
|
sameSite?: 'Lax' | 'None';
|
|
42
40
|
cookieLifetimeSeconds?: number;
|
|
43
41
|
}): {
|
|
44
42
|
authenticate: (request: Request) => Promise<Principal>;
|
|
45
43
|
withSession<T>(request: Request, scopes: readonly string[], action: (principal: Principal) => Promise<T>): Promise<T>;
|
|
44
|
+
authorize(request: Request): Promise<Response>;
|
|
46
45
|
join(request: Request): Promise<Response>;
|
|
47
46
|
};
|
package/dist/browser.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
2
|
import { secureUrl } from './network/transport.js';
|
|
3
|
-
import {
|
|
3
|
+
import { object, parseJson } from './protocol/wire.js';
|
|
4
4
|
import { AuthorityError } from './auth/authority.js';
|
|
5
5
|
const hash = (value) => createHash('sha256').update(value).digest('base64url');
|
|
6
6
|
export async function boundedText(request, max) {
|
|
@@ -30,36 +30,61 @@ export async function boundedText(request, max) {
|
|
|
30
30
|
return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(parts));
|
|
31
31
|
}
|
|
32
32
|
export function createBrowserSessions(options) {
|
|
33
|
-
const endpoint = secureUrl(options.endpoint);
|
|
34
|
-
const
|
|
33
|
+
const endpoint = secureUrl(options.endpoint), name = options.cookieName ?? '__Host-poppy-session';
|
|
34
|
+
const sameSite = options.sameSite ?? 'Lax', lifetime = options.cookieLifetimeSeconds ?? 3600;
|
|
35
35
|
if (!/^[A-Za-z0-9_-]+$/.test(name) || !['Lax', 'None'].includes(sameSite) || !Number.isSafeInteger(lifetime) || lifetime < 1 || !/^[a-z0-9.-]+$/i.test(options.domain))
|
|
36
36
|
throw new TypeError('browser options');
|
|
37
37
|
if (name === '__Host-poppy_join' || (endpoint.hostname !== options.domain.toLowerCase() && !endpoint.hostname.endsWith(`.${options.domain.toLowerCase()}`)))
|
|
38
38
|
throw new TypeError('browser options');
|
|
39
|
-
const
|
|
39
|
+
const failure = new Error('Invalid browser session');
|
|
40
|
+
const invalid = () => failure;
|
|
41
|
+
const authorizationEndpoint = new URL(endpoint);
|
|
42
|
+
authorizationEndpoint.pathname = endpoint.pathname.replace(/\/$/, '') + '/authorize';
|
|
40
43
|
async function authenticate(request) {
|
|
41
44
|
const cookies = (request.headers.get('cookie') ?? '').split(';').map(part => part.trim().split('='));
|
|
42
45
|
const matches = cookies.filter(([key]) => key === name);
|
|
43
46
|
if (matches.length !== 1 || matches[0].length !== 2 || !/^[A-Za-z0-9_-]{43}$/.test(matches[0][1]))
|
|
44
47
|
throw invalid();
|
|
45
48
|
const binding = await options.cookies.get(hash(matches[0][1]));
|
|
46
|
-
if (!binding || binding.expiresAt <= options.now())
|
|
49
|
+
if (!binding?.principal || binding.expiresAt <= options.now())
|
|
47
50
|
throw invalid();
|
|
48
|
-
const
|
|
49
|
-
if (
|
|
51
|
+
const principal = binding.principal;
|
|
52
|
+
if (principal.clientId !== binding.clientId || principal.userId !== binding.userId || principal.sessionId !== binding.sessionId)
|
|
50
53
|
throw invalid();
|
|
51
|
-
return
|
|
54
|
+
return options.policy.withCurrent(principal, [], async () => principal);
|
|
52
55
|
}
|
|
53
56
|
return {
|
|
54
57
|
authenticate,
|
|
55
58
|
async withSession(request, scopes, action) {
|
|
56
59
|
const principal = await authenticate(request);
|
|
57
|
-
return options.policy.withCurrent(principal,
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
return options.policy.withCurrent(principal, scopes, () => action(principal));
|
|
61
|
+
},
|
|
62
|
+
async authorize(request) {
|
|
63
|
+
try {
|
|
64
|
+
if (request.url !== authorizationEndpoint.href || request.method !== 'POST' || request.headers.get('content-type')?.split(';')[0] !== 'application/json')
|
|
65
|
+
throw invalid();
|
|
66
|
+
let value;
|
|
67
|
+
try {
|
|
68
|
+
value = object(parseJson(await boundedText(request, 4096)));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw failure;
|
|
72
|
+
}
|
|
73
|
+
if (typeof value.code !== 'string' || !/^[A-Za-z0-9_-]{1,256}$/.test(value.code))
|
|
74
|
+
throw invalid();
|
|
75
|
+
const principal = await options.authenticator.authenticate(request);
|
|
76
|
+
const success = await options.policy.withCurrent(principal, [], () => options.cookies.authorizeJoin(hash(value.code), principal, options.now()));
|
|
77
|
+
if (!success)
|
|
78
|
+
throw invalid();
|
|
79
|
+
return new Response(null, { status: 204, headers: { 'Cache-Control': 'no-store' } });
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (error instanceof AuthorityError)
|
|
83
|
+
return error.response();
|
|
84
|
+
if (error !== failure)
|
|
85
|
+
throw error;
|
|
86
|
+
return Response.json({ error: 'invalid_request' }, { status: 400, headers: { 'Cache-Control': 'no-store' } });
|
|
87
|
+
}
|
|
63
88
|
},
|
|
64
89
|
async join(request) {
|
|
65
90
|
try {
|
|
@@ -68,7 +93,7 @@ export function createBrowserSessions(options) {
|
|
|
68
93
|
if (request.method === 'GET') {
|
|
69
94
|
const code = randomBytes(32).toString('base64url'), cookie = randomBytes(32).toString('base64url');
|
|
70
95
|
await options.cookies.putJoin(hash(code), { cookieHash: hash(cookie), expiresAt: options.now() + 60000 });
|
|
71
|
-
return new Response(`<!doctype html><title>Connecting</title><form method="post"><input type="hidden" name="code" value="${code}"><
|
|
96
|
+
return new Response(`<!doctype html><title>Connecting</title><form method="post"><input type="hidden" name="code" value="${code}"><button>Continue</button></form>`, { headers: {
|
|
72
97
|
'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store',
|
|
73
98
|
'Content-Security-Policy': "default-src 'none'; form-action 'self'; frame-ancestors 'none'", 'Cross-Origin-Opener-Policy': 'same-origin',
|
|
74
99
|
'Set-Cookie': '__Host-poppy_join=' + cookie + '; Path=/; Max-Age=60; Secure; HttpOnly; SameSite=Strict',
|
|
@@ -76,33 +101,40 @@ export function createBrowserSessions(options) {
|
|
|
76
101
|
}
|
|
77
102
|
if (request.method !== 'POST' || request.headers.get('content-type')?.split(';')[0] !== 'application/x-www-form-urlencoded')
|
|
78
103
|
throw invalid();
|
|
79
|
-
const fields = new URLSearchParams(await boundedText(request, 16384));
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (codes.length !== 1 ||
|
|
104
|
+
const fields = new URLSearchParams(await boundedText(request, 16384)), codes = fields.getAll('code');
|
|
105
|
+
const issued = [];
|
|
106
|
+
for (const code of new Set(codes))
|
|
107
|
+
issued.push(await options.cookies.consumeJoin(hash(code)));
|
|
108
|
+
if (codes.length !== 1 || [...fields.keys()].some(key => key !== 'code'))
|
|
84
109
|
throw invalid();
|
|
85
110
|
const binding = issued[0];
|
|
86
111
|
const cookies = (request.headers.get('cookie') ?? '').split(';').map(part => part.trim().split('='));
|
|
87
112
|
const matches = cookies.filter(([key]) => key === '__Host-poppy_join');
|
|
88
|
-
if (!binding || binding.expiresAt <= options.now() || matches.length !== 1 || matches[0].length !== 2 || hash(matches[0][1]) !== binding.cookieHash)
|
|
113
|
+
if (!binding?.principal || binding.expiresAt <= options.now() || matches.length !== 1 || matches[0].length !== 2 || hash(matches[0][1]) !== binding.cookieHash)
|
|
89
114
|
throw invalid();
|
|
90
115
|
const origin = request.headers.get('origin'), site = request.headers.get('sec-fetch-site');
|
|
91
|
-
if ((origin !== null && origin !==
|
|
92
|
-
throw invalid();
|
|
93
|
-
const verified = await verifyDpopProof(fields.get('proof'), { method: 'POST', url: options.endpoint, nowMs: options.now() });
|
|
94
|
-
if (verified.claims.nonce !== codes[0])
|
|
116
|
+
if ((origin !== null && origin !== endpoint.origin) || (site !== null && !['same-origin', 'none'].includes(site)))
|
|
95
117
|
throw invalid();
|
|
96
|
-
const
|
|
97
|
-
const seconds = Math.floor(Math.min(
|
|
98
|
-
if (seconds < 1)
|
|
118
|
+
const principal = { ...binding.principal };
|
|
119
|
+
const seconds = Math.floor(Math.min((principal.sessionExpiresAt ?? principal.tokenExpiresAt) - options.now(), lifetime * 1000) / 1000);
|
|
120
|
+
if (seconds < 1 || (principal.accountId && !options.clearLogin))
|
|
99
121
|
throw invalid();
|
|
122
|
+
principal.tokenExpiresAt = options.now() + seconds * 1000;
|
|
100
123
|
const value = randomBytes(32).toString('base64url');
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
124
|
+
return await options.policy.withCurrent(principal, [], async () => {
|
|
125
|
+
const headers = new Headers({ Location: new URL('/', options.endpoint).href, 'Cache-Control': 'no-store' });
|
|
126
|
+
if (principal.accountId)
|
|
127
|
+
await options.clearLogin(request, headers);
|
|
128
|
+
const previous = cookies.filter(([key]) => key === name);
|
|
129
|
+
if (previous.length > 1)
|
|
130
|
+
throw invalid();
|
|
131
|
+
if (previous[0]?.[1])
|
|
132
|
+
await options.cookies.remove(hash(previous[0][1]));
|
|
133
|
+
await options.cookies.put(hash(value), { clientId: principal.clientId, userId: principal.userId, sessionId: principal.sessionId, ...(principal.issuerGeneration === undefined ? {} : { issuerGeneration: principal.issuerGeneration }), principal, expiresAt: principal.tokenExpiresAt });
|
|
134
|
+
headers.append('Set-Cookie', `${name}=${value}; Path=/; Secure; HttpOnly; SameSite=${sameSite}; Max-Age=${seconds}`);
|
|
135
|
+
headers.append('Set-Cookie', '__Host-poppy_join=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Strict');
|
|
136
|
+
return new Response(null, { status: 303, headers });
|
|
137
|
+
});
|
|
106
138
|
}
|
|
107
139
|
catch {
|
|
108
140
|
return new Response('Invalid browser session', { status: 400, headers: { 'Cache-Control': 'no-store', 'Content-Type': 'text/plain' } });
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import type { OperationApproval, OperationRequest } from '../protocol/operations.js';
|
|
2
|
+
import type { MetadataResolver } from '../network/discovery.js';
|
|
1
3
|
import type { ConversationEvent, JsonObject, Message, AuthorType, Status } from '../protocol/wire.js';
|
|
2
4
|
import type { ConversationStore, ConversationNotifications } from './store.js';
|
|
3
|
-
import type { AcceptedAuthority, AuthorityPolicy, RequestAuthenticator, WorkRevocationPolicy } from '../auth/authority.js';
|
|
5
|
+
import type { AcceptedAuthority, AuthorityPolicy, Principal, RequestAuthenticator, WorkRevocationPolicy } from '../auth/authority.js';
|
|
4
6
|
export interface ConversationRecord {
|
|
5
7
|
id: string;
|
|
6
8
|
issuer: string;
|
|
7
9
|
clientId: string;
|
|
8
10
|
userId: string;
|
|
9
11
|
bindingId: string;
|
|
12
|
+
operationApprovals?: string[];
|
|
10
13
|
lastUserMessage?: Message;
|
|
11
14
|
status: Status;
|
|
12
15
|
responder: AuthorType;
|
|
@@ -53,6 +56,8 @@ export interface ConversationStoreState {
|
|
|
53
56
|
work: WorkRecord[];
|
|
54
57
|
}
|
|
55
58
|
export interface ConversationOptions {
|
|
59
|
+
/** Enable only with durable business execution and published Company v2 capability. Unsupported clients are refused. */
|
|
60
|
+
operations?: MetadataResolver;
|
|
56
61
|
endpoint: string;
|
|
57
62
|
store: ConversationStore;
|
|
58
63
|
authenticator: RequestAuthenticator;
|
|
@@ -92,6 +97,11 @@ export interface ConversationService {
|
|
|
92
97
|
/** Protocol errors become responses; anything else is re-thrown, so hosts must catch it and answer 500. */
|
|
93
98
|
handle(request: Request): Promise<Response>;
|
|
94
99
|
claim(): Promise<WorkClaim | null>;
|
|
100
|
+
operationContext(work: WorkRef): Promise<{
|
|
101
|
+
principal: Principal;
|
|
102
|
+
request: OperationRequest;
|
|
103
|
+
approval?: OperationApproval;
|
|
104
|
+
}>;
|
|
95
105
|
renew(work: WorkRef): Promise<void>;
|
|
96
106
|
/** Conversation history for a model or handler; account-bound history needs the bound account and configured historyScopes. */
|
|
97
107
|
history(work: WorkRef): Promise<ConversationEvent[]>;
|