@poppykit/server 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.
Files changed (52) hide show
  1. package/API.md +10 -3
  2. package/AUTH.md +120 -124
  3. package/CHANGELOG.md +13 -1
  4. package/CONFORMANCE.md +73 -0
  5. package/CONTRIBUTING.md +12 -11
  6. package/DELIVERY.md +2 -0
  7. package/INTEGRATIONS.md +74 -81
  8. package/OPERATIONS.md +142 -0
  9. package/README.md +11 -20
  10. package/RELEASING.md +2 -0
  11. package/SECURITY.md +2 -0
  12. package/STORAGE.md +56 -17
  13. package/dist/adapter-contract.d.ts +18 -0
  14. package/dist/adapter-contract.js +79 -0
  15. package/dist/auth/authenticator.d.ts +3 -1
  16. package/dist/auth/authenticator.js +34 -29
  17. package/dist/auth/authority.d.ts +8 -2
  18. package/dist/auth/authority.js +7 -2
  19. package/dist/auth/session.d.ts +47 -0
  20. package/dist/auth/session.js +94 -0
  21. package/dist/browser.d.ts +15 -16
  22. package/dist/browser.js +65 -33
  23. package/dist/conversation/service.d.ts +11 -1
  24. package/dist/conversation/service.js +39 -9
  25. package/dist/feedback.d.ts +1 -1
  26. package/dist/feedback.js +5 -5
  27. package/dist/index.d.ts +5 -1
  28. package/dist/index.js +3 -0
  29. package/dist/mcp.d.ts +1 -1
  30. package/dist/mcp.js +8 -4
  31. package/dist/network/discovery.d.ts +31 -13
  32. package/dist/network/discovery.js +103 -66
  33. package/dist/operations.d.ts +115 -0
  34. package/dist/operations.js +212 -0
  35. package/dist/postgres.d.ts +6 -0
  36. package/dist/postgres.js +72 -4
  37. package/dist/protocol/operations.d.ts +51 -0
  38. package/dist/protocol/operations.js +98 -0
  39. package/dist/protocol/session.d.ts +28 -0
  40. package/dist/protocol/session.js +93 -0
  41. package/dist/protocol/sse.d.ts +1 -0
  42. package/dist/protocol/sse.js +1 -1
  43. package/dist/protocol/wire.d.ts +8 -4
  44. package/dist/protocol/wire.js +37 -15
  45. package/dist/reference-auth/index.d.ts +10 -16
  46. package/dist/reference-auth/index.js +225 -430
  47. package/dist/sqlite.d.ts +11 -0
  48. package/dist/sqlite.js +84 -4
  49. package/examples/operations.mjs +18 -0
  50. package/examples/quickstart.mjs +8 -5
  51. package/migrations/RETIREMENT.md +77 -0
  52. package/package.json +5 -2
@@ -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: Pick<ExternalAuthorityAdapter, 'introspect'>;
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 && (endpoint.resource !== `${endpoint.origin}${endpoint.pathPrefix}` || secureUrl(endpoint.mcp.metadataUrl).href !== endpoint.mcp.metadataUrl))
11
- throw new TypeError('Invalid MCP resource');
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
- if (endpoint.mcp && request.url !== endpoint.resource)
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 (endpoint.mcp && /^Bearer /i.test(request.headers.get('authorization') ?? '')) {
25
- const bearer = /^Bearer ([A-Za-z0-9\-._~+/]+=*)$/i.exec(request.headers.get('authorization') ?? '')?.[1];
26
- if (!bearer || bearer.length > 4096)
35
+ if (scheme === 'bearer') {
36
+ if (dpopJkt !== null)
27
37
  throw new AuthorityError('invalid_token');
28
- const { principal, dpopJkt } = await options.authority.introspect(bearer, endpoint.resource);
29
- // Ordinary MCP issuers return null only for unbound Bearer tokens. A bound token never downgrades.
30
- if (dpopJkt !== null || principal.tokenExpiresAt <= options.now())
38
+ }
39
+ else {
40
+ if (endpoint.mcp && !endpoint.mcp.dpop)
31
41
  throw new AuthorityError('invalid_token');
32
- if (!principal.userId && !principal.accountId)
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 (endpoint.mcp && !endpoint.mcp.dpop)
37
- throw new AuthorityError('invalid_token');
38
- const token = DPOP_AUTHORIZATION.exec(request.headers.get('authorization') ?? '')?.[1];
39
- if (!token || token.length > 4096)
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
  }
@@ -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' | 'sign_in_required' | 'insufficient_scope';
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
  }
@@ -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 === 'sign_in_required' || code === 'insufficient_scope' ? 403 : 401;
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
- this.headers = new Headers({ 'WWW-Authenticate': `DPoP error="${code}"${scope}, algs="ES256"` });
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 BrowserSession extends BrowserBinding {
9
- principal: Principal;
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
- putJoin(codeHash: string, binding: {
24
- cookieHash: string;
25
- expiresAt: number;
26
- }): Promise<void>;
27
- consumeJoin(codeHash: string): Promise<{
28
- cookieHash: string;
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
- authority: BrowserSessionAuthority;
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 { verifyDpopProof } from './auth/dpop.js';
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 name = options.cookieName ?? '__Host-poppy-session', sameSite = options.sameSite ?? 'Lax', lifetime = options.cookieLifetimeSeconds ?? 3600;
33
+ const endpoint = secureUrl(options.endpoint), name = options.cookieName ?? '__Host-poppy-session';
34
+ const sameSite = options.sameSite ?? 'Lax', lifetime = options.cookieLifetimeSeconds ?? 3600;
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 invalid = () => new Error('Invalid browser session');
39
+ const failure = new Error('Invalid browser session');
40
+ const invalid = () => failure;
41
+ const authorizationEndpoint = new URL(endpoint);
42
+ authorizationEndpoint.pathname = endpoint.pathname.replace(/\/$/, '') + '/authorize';
40
43
  async function authenticate(request) {
41
44
  const cookies = (request.headers.get('cookie') ?? '').split(';').map(part => part.trim().split('='));
42
45
  const matches = cookies.filter(([key]) => key === name);
43
46
  if (matches.length !== 1 || matches[0].length !== 2 || !/^[A-Za-z0-9_-]{43}$/.test(matches[0][1]))
44
47
  throw invalid();
45
48
  const binding = await options.cookies.get(hash(matches[0][1]));
46
- if (!binding || binding.expiresAt <= options.now())
49
+ if (!binding?.principal || binding.expiresAt <= options.now())
47
50
  throw invalid();
48
- const session = await options.authority.current(binding);
49
- if (session.expiresAt <= options.now() || session.clientId !== binding.clientId || session.userId !== binding.userId || session.sessionId !== binding.sessionId || session.issuerGeneration !== binding.issuerGeneration)
51
+ const principal = binding.principal;
52
+ if (principal.clientId !== binding.clientId || principal.userId !== binding.userId || principal.sessionId !== binding.sessionId)
50
53
  throw invalid();
51
- return session.principal;
54
+ return options.policy.withCurrent(principal, [], async () => principal);
52
55
  }
53
56
  return {
54
57
  authenticate,
55
58
  async withSession(request, scopes, action) {
56
59
  const principal = await authenticate(request);
57
- return options.policy.withCurrent(principal, [], async () => {
58
- const current = await options.authority.current(principal);
59
- if (!scopes.every(scope => current.principal.scopes.includes(scope)))
60
- throw new AuthorityError('insufficient_scope');
61
- return action(current.principal);
62
- });
60
+ return options.policy.withCurrent(principal, scopes, () => action(principal));
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}"><input type="text" name="proof"></form>`, { headers: {
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 codes = fields.getAll('code');
81
- // Consume every identifiable code before checking cookies, origin, proof or duplicate fields.
82
- const issued = await Promise.all([...new Set(codes)].map(code => options.cookies.consumeJoin(hash(code))));
83
- if (codes.length !== 1 || fields.getAll('proof').length !== 1 || [...fields.keys()].some(key => !['code', 'proof'].includes(key)))
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 !== new URL(options.endpoint).origin) || (site !== null && !['same-origin', 'none'].includes(site)))
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 session = await options.authority.join(verified.jkt);
97
- const seconds = Math.floor(Math.min(session.expiresAt - options.now(), lifetime * 1000) / 1000);
98
- if (seconds < 1)
118
+ const principal = { ...binding.principal };
119
+ const seconds = Math.floor(Math.min((principal.sessionExpiresAt ?? principal.tokenExpiresAt) - options.now(), lifetime * 1000) / 1000);
120
+ if (seconds < 1 || (principal.accountId && !options.clearLogin))
99
121
  throw invalid();
122
+ principal.tokenExpiresAt = options.now() + seconds * 1000;
100
123
  const value = randomBytes(32).toString('base64url');
101
- const { clientId, userId, sessionId, issuerGeneration } = session;
102
- await options.cookies.put(hash(value), { clientId, userId, sessionId, ...(issuerGeneration === undefined ? {} : { issuerGeneration }), expiresAt: options.now() + seconds * 1000 });
103
- const headers = new Headers({ Location: new URL('/', options.endpoint).href, 'Set-Cookie': `${name}=${value}; Path=/; Secure; HttpOnly; SameSite=${sameSite}; Max-Age=${seconds}`, 'Cache-Control': 'no-store' });
104
- headers.append('Set-Cookie', '__Host-poppy_join=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Strict');
105
- return new Response(null, { status: 303, headers });
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[]>;