@poppykit/server 0.3.1 → 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 (57) hide show
  1. package/API.md +11 -3
  2. package/AUTH.md +120 -95
  3. package/CHANGELOG.md +18 -0
  4. package/CONFORMANCE.md +73 -0
  5. package/CONTRIBUTING.md +13 -10
  6. package/DELIVERY.md +21 -1
  7. package/INTEGRATIONS.md +72 -69
  8. package/OPERATIONS.md +142 -0
  9. package/README.md +14 -17
  10. package/RELEASING.md +2 -0
  11. package/SECURITY.md +2 -0
  12. package/STORAGE.md +57 -2
  13. package/dist/adapter-contract.d.ts +18 -0
  14. package/dist/adapter-contract.js +90 -11
  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 -13
  22. package/dist/browser.js +80 -46
  23. package/dist/conversation/service.d.ts +18 -8
  24. package/dist/conversation/service.js +60 -28
  25. package/dist/feedback.d.ts +1 -1
  26. package/dist/feedback.js +5 -5
  27. package/dist/index.d.ts +6 -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 +51 -22
  32. package/dist/network/discovery.js +159 -69
  33. package/dist/network/redirect.d.ts +3 -0
  34. package/dist/network/redirect.js +38 -0
  35. package/dist/network/tasks.d.ts +23 -0
  36. package/dist/network/tasks.js +45 -0
  37. package/dist/operations.d.ts +115 -0
  38. package/dist/operations.js +212 -0
  39. package/dist/postgres.d.ts +6 -0
  40. package/dist/postgres.js +92 -6
  41. package/dist/protocol/operations.d.ts +51 -0
  42. package/dist/protocol/operations.js +98 -0
  43. package/dist/protocol/session.d.ts +28 -0
  44. package/dist/protocol/session.js +93 -0
  45. package/dist/protocol/sse.d.ts +1 -0
  46. package/dist/protocol/sse.js +1 -1
  47. package/dist/protocol/wire.d.ts +20 -15
  48. package/dist/protocol/wire.js +70 -22
  49. package/dist/reference-auth/index.d.ts +17 -12
  50. package/dist/reference-auth/index.js +252 -386
  51. package/dist/reference-auth/store.d.ts +2 -1
  52. package/dist/sqlite.d.ts +11 -0
  53. package/dist/sqlite.js +104 -6
  54. package/examples/operations.mjs +18 -0
  55. package/examples/quickstart.mjs +10 -6
  56. package/migrations/RETIREMENT.md +77 -0
  57. package/package.json +5 -2
@@ -23,15 +23,72 @@ 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 = {
29
86
  signedOut: base,
30
- account1: { ...base, accountId: 'acct_1', scopes: ['poppy:read'] },
31
- account2: { ...base, accountId: 'acct_2', scopes: ['poppy:read'] },
87
+ account1: { ...base, accountId: 'acct_1', scopes: ['orders:read'] },
88
+ account2: { ...base, accountId: 'acct_2', scopes: ['orders:read'] },
32
89
  };
33
- const message = { id: 'msg_1', sender: 'agent', text: 'Where is order 1042?' };
34
- const reply = { id: 'msg_r1', sender: 'agent', text: 'Shipped.' };
90
+ const message = { id: 'msg_1', author: { type: 'agent' }, text: 'Where is order 1042?' };
91
+ const reply = { id: 'msg_r1', author: { type: 'agent' }, text: 'Shipped.' };
35
92
  function check(held, needed, action) {
36
93
  if (!needed.every(scope => held.includes(scope)))
37
94
  return Promise.reject(new AuthorityError('insufficient_scope'));
@@ -50,7 +107,7 @@ function serviceOn(store, clock) {
50
107
  return principal;
51
108
  },
52
109
  };
53
- return createConversationService({ endpoint, store, authenticator, policy, clock, leaseMs: 1000, supportedScopes: ['poppy:read'] });
110
+ return createConversationService({ historyScopes: ['orders:read'], endpoint, store, authenticator, policy, clock, leaseMs: 1000, supportedScopes: ['orders:read'] });
54
111
  }
55
112
  async function call(service, path, as, body, query = '') {
56
113
  const response = await service.handle(new Request(`${endpoint}${path}${query}`, {
@@ -114,12 +171,12 @@ export function runConversationAdapterContract(fixture) {
114
171
  const parentWork = (await a.claim());
115
172
  const outcome = (promise) => promise.then(value => value.accountId, error => String(error.code));
116
173
  const results = await Promise.all([
117
- outcome(a.requireAccount(parentWork, ['poppy:read'])),
174
+ outcome(a.requireAccount(parentWork, ['orders:read'])),
118
175
  (async () => {
119
- const opened = await call(b, '', 'account2', { parent_conversation_id: parent, message: { id: 'msg_c', sender: 'human', text: 'Hi.' } });
176
+ const opened = await call(b, '', 'account2', { parent_conversation_id: parent, message: { id: 'msg_c', author: { type: 'human' }, text: 'Hi.' } });
120
177
  if (opened.status !== 201)
121
178
  return String(opened.body.error);
122
- return outcome(b.claim().then(work => b.requireAccount(work, ['poppy:read'])));
179
+ return outcome(b.claim().then(work => b.requireAccount(work, ['orders:read'])));
123
180
  })(),
124
181
  ]);
125
182
  const winners = results.filter(result => result.startsWith('acct_'));
@@ -129,7 +186,7 @@ export function runConversationAdapterContract(fixture) {
129
186
  assert.deepEqual(state.bindings[parent], { accountId: winners[0] });
130
187
  const loser = winners[0] === 'acct_1' ? 'account2' : 'account1';
131
188
  for (const id of Object.keys(state.conversations))
132
- assert.deepEqual((await call(b, `/${id}/events`, loser)).body, { error: 'sign_in_required' });
189
+ assert.deepEqual((await call(b, `/${id}/events`, loser)).body, { error: 'insufficient_scope' });
133
190
  }));
134
191
  test('adapter contract: lease reclaim on the other handle fences the old worker; output IDs dedupe', () => withFixture(async ({ store, second }) => {
135
192
  const [a, b] = [serviceOn(store, clock), serviceOn(second, clock)];
@@ -155,8 +212,8 @@ export function runConversationAdapterContract(fixture) {
155
212
  await a.reply(work, outputId, value);
156
213
  await b.reply(work, outputId, value);
157
214
  await assert.rejects(b.reply(work, outputId, { ...value, text: 'changed' }), { code: 'output_conflict' });
158
- await a.requestAuthorization(work, `auth_${outputId}`, ['poppy:read']);
159
- await b.requestAuthorization(work, `auth_${outputId}`, ['poppy:read']);
215
+ await a.requestAuthorization(work, `auth_${outputId}`, ['orders:read']);
216
+ await b.requestAuthorization(work, `auth_${outputId}`, ['orders:read']);
160
217
  await a.requestUser(work, `user_${outputId}`, outputId);
161
218
  await b.requestUser(work, `user_${outputId}`, outputId);
162
219
  await assert.rejects(b.requestUser(work, `user_${outputId}`, 'changed'), { code: 'output_conflict' });
@@ -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,21 +1,15 @@
1
- import type { MetadataResolver } from './network/discovery.js';
2
- import type { Principal, AuthorityPolicy } from './auth/authority.js';
1
+ import type { Principal, AuthorityPolicy, RequestAuthenticator } from './auth/authority.js';
3
2
  export interface BrowserBinding {
4
3
  clientId: string;
5
4
  userId: string;
6
5
  sessionId: string;
7
6
  issuerGeneration?: string;
7
+ principal?: Principal;
8
8
  }
9
- export interface BrowserSession extends BrowserBinding {
10
- principal: Principal;
9
+ export interface BrowserJoin {
10
+ cookieHash: string;
11
11
  expiresAt: number;
12
- }
13
- export interface BrowserSessionAuthority {
14
- join(binding: BrowserBinding, assertion: {
15
- jti: string;
16
- expiresAt: number;
17
- }): Promise<BrowserSession>;
18
- current(binding: BrowserBinding): Promise<BrowserSession>;
12
+ principal?: Principal;
19
13
  }
20
14
  export interface BrowserCookieStore {
21
15
  put(hash: string, binding: BrowserBinding & {
@@ -24,21 +18,29 @@ export interface BrowserCookieStore {
24
18
  get(hash: string): Promise<(BrowserBinding & {
25
19
  expiresAt: number;
26
20
  }) | 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>;
27
27
  }
28
28
  export declare function boundedText(request: Request, max: number): Promise<string>;
29
29
  export declare function createBrowserSessions(options: {
30
30
  endpoint: string;
31
31
  domain: string;
32
- metadata: MetadataResolver;
33
- authority: BrowserSessionAuthority;
32
+ authenticator: RequestAuthenticator;
34
33
  cookies: BrowserCookieStore;
35
34
  policy: AuthorityPolicy;
36
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>;
37
38
  cookieName?: string;
38
39
  sameSite?: 'Lax' | 'None';
39
40
  cookieLifetimeSeconds?: number;
40
41
  }): {
41
42
  authenticate: (request: Request) => Promise<Principal>;
42
43
  withSession<T>(request: Request, scopes: readonly string[], action: (principal: Principal) => Promise<T>): Promise<T>;
44
+ authorize(request: Request): Promise<Response>;
43
45
  join(request: Request): Promise<Response>;
44
46
  };