@poppykit/server 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/API.md CHANGED
@@ -7,6 +7,7 @@ completion for complete options and results; this map identifies the integration
7
7
  | --- | --- | --- |
8
8
  | `@poppykit/server` | `createRequestAuthenticator`, `AuthorityError`, authority types | [authentication](AUTH.md) |
9
9
  | `@poppykit/server` | `createConversationService`, `createConversationNotifications` | [delivery](DELIVERY.md) |
10
+ | `@poppykit/server` | `Author`, `AuthorType`, `Message`, `RecordedMessage`, conversation/event types | [delivery](DELIVERY.md) |
10
11
  | `@poppykit/server` | `createBrowserSessions`, `createFeedbackHandler`, MCP metadata/error helpers | [integrations](INTEGRATIONS.md) |
11
12
  | `@poppykit/server/reference-auth` | `createReferenceIssuer`, `createMetadataResolver`, `createNodeTransport` | [authentication](AUTH.md) |
12
13
  | `@poppykit/server/sqlite` | `SqliteCompanyDatabase` | [storage](STORAGE.md) |
package/AUTH.md CHANGED
@@ -7,10 +7,24 @@ order authorization checks with protected work. The reference issuer is a
7
7
  separate `/reference-auth` import and uses the shared company database coordinator
8
8
  for immediate revocation visibility.
9
9
 
10
+ For draft 0.3 discovery configure `protectedResources`, including
11
+ `https://company.example` (host only, no port/path/trailing slash), and
12
+ `supportedScopes`, the company-defined account scopes. This replaces
13
+ `poppyDomains` and `directScopes` in reference issuer options. Direct and Device
14
+ use that same supported list; Device configuration no longer has a `scopes` field.
15
+ Mediated retains its own subset. Publish descriptions in company `auth.scopes` or
16
+ a listed OpenAPI document. Names starting with `poppy:` are reserved. Issuer
17
+ metadata emits the mandatory session/refresh grants and enabled sign-in grants.
18
+ Token exchange for Bearer-only MCP remains a later migration stage.
19
+
10
20
  Reference Direct sign-in uses `beginAuthorization` / `completeAuthorization`.
11
21
  The host authenticates the human and protects consent with its own browser
12
22
  session/CSRF controls; pass a high-entropy browser binding. Registered callback
13
- URLs must be HTTPS on the client metadata domain and exactly match registration.
23
+ URLs must be HTTPS on the client metadata domain, or RFC 8252 HTTP loopback IP
24
+ callbacks (`127.0.0.1` or `[::1]`). Registration matching permits only the loopback
25
+ port to vary; exchange must repeat the actual authorization redirect exactly.
26
+ Direct requests require `dpop_jkt`; codes retain it and exchange requires that
27
+ Session Key. Missing bindings in old pending authorizations/codes fail closed.
14
28
 
15
29
  Direct hosts must configure `authorizationResultCipher: { seal, open }` with
16
30
  synchronous authenticated encryption and a durable protected key before calling
@@ -36,13 +50,13 @@ preserving the accepted scope ceiling, reset generation and configured revocatio
36
50
  Account Token rotation retains all issued hashes through the fixed grant lifetime.
37
51
  Replaying any ancestor revokes its entire lineage and signs its Sessions out;
38
52
  rotation never extends the grant. Client authentication remains mandatory, while
39
- Session Tokens can move to a new DPoP key as the protocol permits. Shared provider
53
+ Account Tokens can start Sessions with new keys; a Session never changes its key. Shared provider
40
54
  signing does not bind a copied refresh token to one provider enrollment. Keep
41
55
  tokens secret and serialize rotation; never retry a lost token response blindly.
42
56
  Bounded database cleanup removes hashes only after their lineage is expired,
43
57
  revoked or absent. No plaintext callback code is persisted.
44
58
 
45
- Upgrade prerequisite: older issuers discarded ancestors beyond the previous hash.
59
+ Pre-version-3 upgrade prerequisite: older issuers discarded ancestors beyond the previous hash.
46
60
  Those missing hashes cannot be reconstructed by this update. Before public
47
61
  enablement, revoke every pre-upgrade live lineage through the issuer's normal
48
62
  revocation/disconnect flow and require fresh consent, or prove it has expired.
@@ -50,12 +64,21 @@ Until then, the full-ancestor guarantee applies only to newly created lineages.
50
64
  This update does not reset stored data or silently revoke legacy grants.
51
65
 
52
66
  Enable reference Device sign-in with `device: { endpoint, verificationUri,
53
- scopes }`. Route the configured device endpoint to `deviceAuthorization`.
67
+ }`. Route the configured device endpoint to `deviceAuthorization`.
54
68
  On the human page call `beginDeviceAuthorization(userCode, browserBinding)` and
55
69
  show the client identity and requested scopes. After an authenticated explicit
56
70
  approve/decline action call `completeDeviceAuthorization`. Opening a link must
57
71
  never approve it. The token endpoint handles durable polling intervals, slowdown,
58
72
  denial, expiry, partial consent, one-use codes and the Session's sticky account.
73
+ The first authenticated poll atomically binds the device code to its live Session
74
+ Key, including pending/slow-down polls. Other keys receive `invalid_grant`.
75
+ Old device records without the new binding slot require a fresh flow. Prefer
76
+ Direct whenever viable; enable Device only for appropriate device constraints
77
+ under RFC 10027. Verification links remain HTTPS on issuer/protected-resource hosts.
78
+
79
+ Hosted consent can resolve `sessionForKey({ clientId, userId, sessionKey })` to the
80
+ current company Session without treating a client's local Session ID as a server
81
+ ID. It validates live client/User ownership and returns current account/scopes.
59
82
 
60
83
  Enable Mediated sign-in with `mediated: { endpoint, scopes, fields,
61
84
  authenticate, verifyCode }` and route that endpoint and its sign-in-ID children
@@ -88,9 +111,15 @@ revision. Revision and per-account fence records remain until explicit issuer
88
111
  reset and never count as pending requests. Terminal method tombstones also do
89
112
  not consume pending quota.
90
113
 
91
- Issuer state is version 3. Older development state is refused without mutation;
92
- there is no automatic migration. Follow the explicit stop/backup/reset procedure
93
- in [STORAGE.md](STORAGE.md) only for state you choose to discard.
114
+ Issuer state is version 4. SQLite/PostgreSQL upgrade normalized version 3 under
115
+ their writer lock; grant lineage survives. Earlier unsupported development versions
116
+ still fail closed. See [STORAGE.md](STORAGE.md) for the exact upgrade behavior.
117
+
118
+ Token admission selects an internal Session ID atomically by the DPoP thumbprint.
119
+ It checks the client, User and optional assertion `agent_id` against the existing
120
+ Session. Known ended keys return `invalid_grant`; key mappings/tombstones are
121
+ retained indefinitely in this reference implementation. Token requests reject the
122
+ old `session_id` parameter; responses omit `session_id` and `signed_in`.
94
123
 
95
124
  Advertise only configured methods in your `poppy.json`; `authorizationMetadata()`
96
125
  adds the device endpoint only when enabled. There is no login/consent UI, identity
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 — 2026-10-28
4
+
5
+ Target pinned Personal Agent Protocol draft 0.3. Break discovery, proof-key grants, author, browser joining and MCP Token Exchange contracts. Upgrade issuer state 3→4; stop old writers and reset conversation state to version 2. Require explicit company history scopes.
6
+
7
+ Local migration candidate only. Publication and deployment require separate approval.
8
+
3
9
  ## 0.3.1
4
10
 
5
11
  Support Node 24.21.0 and later within Node 24 under the owner-accepted runtime policy.
package/CONTRIBUTING.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # Contributing
2
2
 
3
- Follow the live draft 0.2 [specification](https://personalagentprotocol.org/docs/0.2/spec)
4
- and [guides](https://personalagentprotocol.org/docs/0.2/guides). Tests/snapshots are
5
- supporting evidence. Demo/business workflows and individual extensions are excluded.
3
+ Follow the pinned draft 0.3 [specification](https://personalagentprotocol.org/docs/0.3/spec)
4
+ and [guides](https://personalagentprotocol.org/docs/0.3/guides). Tests/snapshots are
5
+ supporting evidence. This unreleased checkout has completed only the discovery
6
+ stage of migration; auth, wire/storage and integrations still need their stages.
7
+ Demo/business workflows and individual extensions are excluded.
6
8
  Code is [MIT](LICENSE); confirm rights for third-party contributions.
7
9
 
8
10
  Use Node 24.21.0, TypeScript 5.9.3 and @types/node 24.10.0.
package/DELIVERY.md CHANGED
@@ -1,8 +1,13 @@
1
1
  # Conversation delivery and transport
2
2
 
3
- The SDK targets Personal Agent Protocol draft 0.2. Company content, messages,
3
+ Conversation messages and authorization events now use draft 0.3. Company content, messages,
4
4
  unknown events and partial text remain untrusted data for the host application.
5
5
 
6
+ Account-history access requires explicit, nonempty company `historyScopes` in
7
+ `createConversationService`, for example `['orders:read']`. There is no protocol
8
+ read-scope default. The configured scopes and account/owner checks apply to HTTP
9
+ reads and worker history; sending alone grants no read access.
10
+
6
11
  ## Client interoperability
7
12
 
8
13
  Clients keep immutable retry targets/message IDs, explicitly acknowledge named
@@ -76,3 +81,16 @@ Pruning is explicit operator policy, never automatic data loss. It preserves old
76
81
  message receipts and cursor identity. Concurrent reads see a coherent snapshot or
77
82
  `cursor_expired`. Invalid/expired waited-POST cursors are rejected before accepting
78
83
  the new write; an existing message ID still returns its original receipt.
84
+
85
+ ## Unreleased draft 0.3 breaking change
86
+
87
+ Protocol messages use `author: { type: 'agent' | 'human' }`; company authors may
88
+ also carry `id`, `name` and an HTTPS `image`. Responder checks use `author.type`,
89
+ and only the server assigns recorded `role`. Authorization events always use
90
+ `insufficient_scope` and name required scopes, including signed-out requests.
91
+
92
+ Conversation state version 2 requires an explicit clean reset of old runtime
93
+ state; SQLite/PostgreSQL reject version 1. Stop old writers and clear history,
94
+ receipts, cursor identities, work, client outboxes and retry handles together.
95
+ Preserve required configuration. No historical conversion or dual wire format is
96
+ provided. An accepted request's fingerprint is never rewritten.
package/INTEGRATIONS.md CHANGED
@@ -27,8 +27,12 @@ MCP metadata identifies the exact resource URL, issuer and ES256 support when
27
27
  enabled. Challenges include Bearer and, if supported, DPoP with `resource_metadata`.
28
28
  The exact endpoint accepts no child paths or added query. All other APIs retain
29
29
  DPoP-only authentication. Configure the reference issuer `mcpResources` with the
30
- same `{url,dpop}` list: every grant chooses Bearer for a non-DPoP MCP server even
31
- when a proof was provided. DPoP MCP requires a valid proof. Bearer tokens are
30
+ same `{url,dpop}` list. Non-DPoP MCP targets require Token Exchange of a current
31
+ DPoP Session Token, a matching client assertion and the Session Key proof. Other
32
+ grants reject those targets with invalid_target; exchange rejects DPoP MCP targets.
33
+ Exchange preserves the Session and narrows subject scopes without rotating an
34
+ Account Token. Metadata advertises exchange when a Bearer-only MCP resource exists.
35
+ DPoP MCP continues using normal DPoP token grants. Bearer tokens are
32
36
  resource-bound; DPoP-bound tokens cannot be presented as Bearer.
33
37
 
34
38
  An existing issuer can accept ordinary MCP OAuth clients through its own flow.
@@ -43,7 +47,7 @@ implements personal-agent grants; it does not add a general OAuth login product.
43
47
 
44
48
  ```ts
45
49
  const browser = createBrowserSessions({ endpoint: browserEndpoint, domain: companyDomain,
46
- metadata, authority: referenceIssuer.browserSessions,
50
+ authority: referenceIssuer.browserSessions,
47
51
  policy: referenceIssuer.authority.policy, cookies: cookieStore, now: Date.now,
48
52
  });
49
53
  // Mount browser.join on browserEndpoint; no request-body logging at this route.
@@ -52,15 +56,21 @@ const result = await browser.withSession(request, ['records:read'], principal =>
52
56
 
53
57
  `BrowserCookieStore.put/get` stores SHA256 cookie-value hashes, session bindings and
54
58
  expiry. Supply durable storage with targeted operations and expire old rows. Never
55
- store plaintext cookie values. `BrowserSessionAuthority.join` must atomically
56
- consume assertion jti and validate an active same-client/same-user session; `current`
57
- must read current session permissions. The reference issuer implements both on its
58
- existing durable issuer store/coordinator; SQLite uses targeted browser selectors.
59
+ store plaintext cookie values. The same store must implement `putJoin(codeHash,
60
+ {cookieHash, expiresAt})` and atomic `consumeJoin(codeHash)`, which removes and
61
+ returns the entry even if expired. Expire abandoned joins and rate-limit the public
62
+ route in the host. Cookie/code hashes may be shared durably across handler replicas.
63
+ `BrowserSessionAuthority.join(sessionKey)` resolves an existing active key; it never
64
+ creates a Session. `current` reads current permissions. The reference issuer uses
65
+ targeted key/Session selectors on its existing memory/SQLite/PostgreSQL stores.
59
66
 
60
- The browser handler verifies JWKS signature, asymmetric algorithm, typ, exact aud,
61
- times/lifetime, replay, client/user/session and HTTPS company return URL. Invalid
62
- requests return 400 with no cookie; successful requests set a host-only cookie and
63
- return 303 to the validated URL. Defaults are `__Host-poppy-session`, Path=/, Secure,
67
+ Mount both GET and POST. GET sets only a 60-second Strict __Host-poppy_join cookie
68
+ and emits a random 256-bit hidden code, no-store, frame-ancestors none and same-origin
69
+ opener isolation. POST consumes the submitted code first, including failed cookie,
70
+ origin and proof checks. It requires that browser's cookie and a valid Session Key
71
+ DPoP proof whose nonce is the code and which has no ath. Invalid requests return 400
72
+ with no cookie; success clears the join cookie, sets the Session cookie and returns
73
+ 303 to the endpoint's website root. No provider signing/JWKS assertion is involved. Defaults are `__Host-poppy-session`, Path=/, Secure,
64
74
  HttpOnly, SameSite=Lax and max 3600 seconds capped by the current session lifetime.
65
75
  SameSite=None is supported; Strict is refused. `authenticate` resolves current
66
76
  authority on every request. Use `withSession` for scoped mutations so permission
package/README.md CHANGED
@@ -1,14 +1,19 @@
1
1
  # PoppyKit server
2
2
 
3
+ Draft 0.3 migration (2026-10-28), pinned to the public source captured that day.
4
+ Client/CLI 0.5.0 and server 0.4.0 break the earlier protocol contracts. Stop old
5
+ writers and follow the documented storage/reset procedure before compatible
6
+ writers resume. Registry, deployment and live acceptance have separate receipts.
7
+
3
8
  The Node 24.21.0 minimum is an owner-accepted compatibility baseline. Use a current patched Node 24 runtime where available; this minimum does not establish that older runtimes contain later security fixes. Build and release CI read `.node-version`.
4
9
 
5
- TypeScript SDK for companies implementing [Personal Agent Protocol draft 0.2](https://personalagentprotocol.org/docs/0.2/spec).
10
+ TypeScript SDK for companies implementing [Personal Agent Protocol draft 0.3](https://personalagentprotocol.org/docs/0.3/spec).
6
11
  Verify requests, serve conversations and enforce current authority. Your company
7
12
  supplies accounts, consent UI, agent, business tools and HTTPS routing. [MIT](LICENSE).
8
13
 
9
14
  ## Install
10
15
 
11
- This source contains unreleased repairs; final version selection and publication are pending.
16
+ Use the exact version verified by its protected release workflow; check registry publication before installation.
12
17
 
13
18
  Requires Node **24.x, at least 24.21.0**, and ESM. CI tests Node 24.21.0 on Linux, macOS and Windows;
14
19
  other Node majors are unsupported. SQLite is optional/experimental and
@@ -37,11 +42,12 @@ try {
37
42
  const endpoint = 'https://company.example/conversations';
38
43
  const auth = createReferenceIssuer({
39
44
  issuer, tokenEndpoint: issuer + '/token', revocationEndpoint: issuer + '/revoke',
40
- poppyDomains: ['company.example'], directScopes: [], resources: [endpoint],
45
+ protectedResources: ['https://company.example'], supportedScopes: [], resources: [endpoint],
41
46
  metadata: createMetadataResolver(transport), store: database.issuer(issuer),
42
47
  coordinator: database, now: Date.now,
43
48
  });
44
49
  const service = createConversationService({
50
+ historyScopes: ['orders:read'],
45
51
  endpoint, store: database.conversations(issuer), policy: auth.authority.policy,
46
52
  authenticator: createRequestAuthenticator({ authority: auth.authority,
47
53
  endpoints: [{ origin: 'https://company.example', pathPrefix: '/conversations', resource: endpoint }],
package/STORAGE.md CHANGED
@@ -81,6 +81,22 @@ force another conversation's receipts out of storage.
81
81
 
82
82
  ## Non-durable use and resets
83
83
 
84
+ Normalized issuer state version 3 upgrades to version 4 once, under the existing
85
+ SQLite writer/PostgreSQL namespace lock. Stop old writers before using the new
86
+ adapter. No SQL schema change is needed: `sessionKeys` records use the existing
87
+ namespace/kind/ID primary key. Token selectors load that mapping and its internal
88
+ Session ID in the same transaction as creation, ownership checks and rotation.
89
+ Mappings remain indefinitely after a Session ends, preventing key reuse.
90
+
91
+ Old keys could belong to several Sessions, so the upgrade ends old Sessions and
92
+ tombstones every known old access-token key rather than guessing a mapping.
93
+ Internal Session IDs, sticky account records, stable Users, grants, all retained
94
+ Account Token hashes, revocation fences and accepted-work authority remain.
95
+ Legacy pending authorization/code/Device/Mediated records are retired: restart
96
+ sign-in rather than accepting consent without the new binding. Existing compatible
97
+ Account Tokens can create fresh-key Sessions. Unsupported earlier state versions
98
+ are refused. The upgrade scans a namespace once; normal selectors stay targeted.
99
+
84
100
  `new SqliteCompanyDatabase(':memory:')` is an explicit non-durable adapter for tests
85
101
  and development. Each instance is separate, is lost at close/process death, and
86
102
  offers no cross-process persistence. The test fixture's object store is also
@@ -27,11 +27,11 @@ const endpoint = 'https://contract.example.test/poppy/conversations';
27
27
  const base = { issuer: 'https://auth.contract.test', clientId: 'https://agent.contract.test/client', sessionId: 's1', userId: 'u_1', scopes: [], tokenExpiresAt: 9e12, sessionVersion: 1 };
28
28
  const principals = {
29
29
  signedOut: base,
30
- account1: { ...base, accountId: 'acct_1', scopes: ['poppy:read'] },
31
- account2: { ...base, accountId: 'acct_2', scopes: ['poppy:read'] },
30
+ account1: { ...base, accountId: 'acct_1', scopes: ['orders:read'] },
31
+ account2: { ...base, accountId: 'acct_2', scopes: ['orders:read'] },
32
32
  };
33
- const message = { id: 'msg_1', sender: 'agent', text: 'Where is order 1042?' };
34
- const reply = { id: 'msg_r1', sender: 'agent', text: 'Shipped.' };
33
+ const message = { id: 'msg_1', author: { type: 'agent' }, text: 'Where is order 1042?' };
34
+ const reply = { id: 'msg_r1', author: { type: 'agent' }, text: 'Shipped.' };
35
35
  function check(held, needed, action) {
36
36
  if (!needed.every(scope => held.includes(scope)))
37
37
  return Promise.reject(new AuthorityError('insufficient_scope'));
@@ -50,7 +50,7 @@ function serviceOn(store, clock) {
50
50
  return principal;
51
51
  },
52
52
  };
53
- return createConversationService({ endpoint, store, authenticator, policy, clock, leaseMs: 1000, supportedScopes: ['poppy:read'] });
53
+ return createConversationService({ historyScopes: ['orders:read'], endpoint, store, authenticator, policy, clock, leaseMs: 1000, supportedScopes: ['orders:read'] });
54
54
  }
55
55
  async function call(service, path, as, body, query = '') {
56
56
  const response = await service.handle(new Request(`${endpoint}${path}${query}`, {
@@ -114,12 +114,12 @@ export function runConversationAdapterContract(fixture) {
114
114
  const parentWork = (await a.claim());
115
115
  const outcome = (promise) => promise.then(value => value.accountId, error => String(error.code));
116
116
  const results = await Promise.all([
117
- outcome(a.requireAccount(parentWork, ['poppy:read'])),
117
+ outcome(a.requireAccount(parentWork, ['orders:read'])),
118
118
  (async () => {
119
- const opened = await call(b, '', 'account2', { parent_conversation_id: parent, message: { id: 'msg_c', sender: 'human', text: 'Hi.' } });
119
+ const opened = await call(b, '', 'account2', { parent_conversation_id: parent, message: { id: 'msg_c', author: { type: 'human' }, text: 'Hi.' } });
120
120
  if (opened.status !== 201)
121
121
  return String(opened.body.error);
122
- return outcome(b.claim().then(work => b.requireAccount(work, ['poppy:read'])));
122
+ return outcome(b.claim().then(work => b.requireAccount(work, ['orders:read'])));
123
123
  })(),
124
124
  ]);
125
125
  const winners = results.filter(result => result.startsWith('acct_'));
@@ -129,7 +129,7 @@ export function runConversationAdapterContract(fixture) {
129
129
  assert.deepEqual(state.bindings[parent], { accountId: winners[0] });
130
130
  const loser = winners[0] === 'acct_1' ? 'account2' : 'account1';
131
131
  for (const id of Object.keys(state.conversations))
132
- assert.deepEqual((await call(b, `/${id}/events`, loser)).body, { error: 'sign_in_required' });
132
+ assert.deepEqual((await call(b, `/${id}/events`, loser)).body, { error: 'insufficient_scope' });
133
133
  }));
134
134
  test('adapter contract: lease reclaim on the other handle fences the old worker; output IDs dedupe', () => withFixture(async ({ store, second }) => {
135
135
  const [a, b] = [serviceOn(store, clock), serviceOn(second, clock)];
@@ -155,8 +155,8 @@ export function runConversationAdapterContract(fixture) {
155
155
  await a.reply(work, outputId, value);
156
156
  await b.reply(work, outputId, value);
157
157
  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']);
158
+ await a.requestAuthorization(work, `auth_${outputId}`, ['orders:read']);
159
+ await b.requestAuthorization(work, `auth_${outputId}`, ['orders:read']);
160
160
  await a.requestUser(work, `user_${outputId}`, outputId);
161
161
  await b.requestUser(work, `user_${outputId}`, outputId);
162
162
  await assert.rejects(b.requestUser(work, `user_${outputId}`, 'changed'), { code: 'output_conflict' });
package/dist/browser.d.ts CHANGED
@@ -1,4 +1,3 @@
1
- import type { MetadataResolver } from './network/discovery.js';
2
1
  import type { Principal, AuthorityPolicy } from './auth/authority.js';
3
2
  export interface BrowserBinding {
4
3
  clientId: string;
@@ -11,10 +10,7 @@ export interface BrowserSession extends BrowserBinding {
11
10
  expiresAt: number;
12
11
  }
13
12
  export interface BrowserSessionAuthority {
14
- join(binding: BrowserBinding, assertion: {
15
- jti: string;
16
- expiresAt: number;
17
- }): Promise<BrowserSession>;
13
+ join(sessionKey: string): Promise<BrowserSession>;
18
14
  current(binding: BrowserBinding): Promise<BrowserSession>;
19
15
  }
20
16
  export interface BrowserCookieStore {
@@ -24,12 +20,19 @@ export interface BrowserCookieStore {
24
20
  get(hash: string): Promise<(BrowserBinding & {
25
21
  expiresAt: number;
26
22
  }) | 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>;
27
31
  }
28
32
  export declare function boundedText(request: Request, max: number): Promise<string>;
29
33
  export declare function createBrowserSessions(options: {
30
34
  endpoint: string;
31
35
  domain: string;
32
- metadata: MetadataResolver;
33
36
  authority: BrowserSessionAuthority;
34
37
  cookies: BrowserCookieStore;
35
38
  policy: AuthorityPolicy;
package/dist/browser.js CHANGED
@@ -1,8 +1,6 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
- import { compactVerify, createLocalJWKSet, decodeJwt, errors } from 'jose';
3
2
  import { secureUrl } from './network/transport.js';
4
- import { parseJson, object } from './protocol/wire.js';
5
- import { assertUniqueJwtMembers } from './auth/dpop.js';
3
+ import { verifyDpopProof } from './auth/dpop.js';
6
4
  import { AuthorityError } from './auth/authority.js';
7
5
  const hash = (value) => createHash('sha256').update(value).digest('base64url');
8
6
  export async function boundedText(request, max) {
@@ -32,10 +30,12 @@ export async function boundedText(request, max) {
32
30
  return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(parts));
33
31
  }
34
32
  export function createBrowserSessions(options) {
35
- secureUrl(options.endpoint);
33
+ const endpoint = secureUrl(options.endpoint);
36
34
  const name = options.cookieName ?? '__Host-poppy-session', sameSite = options.sameSite ?? 'Lax', lifetime = options.cookieLifetimeSeconds ?? 3600;
37
35
  if (!/^[A-Za-z0-9_-]+$/.test(name) || !['Lax', 'None'].includes(sameSite) || !Number.isSafeInteger(lifetime) || lifetime < 1 || !/^[a-z0-9.-]+$/i.test(options.domain))
38
36
  throw new TypeError('browser options');
37
+ if (name === '__Host-poppy_join' || (endpoint.hostname !== options.domain.toLowerCase() && !endpoint.hostname.endsWith(`.${options.domain.toLowerCase()}`)))
38
+ throw new TypeError('browser options');
39
39
  const invalid = () => new Error('Invalid browser session');
40
40
  async function authenticate(request) {
41
41
  const cookies = (request.headers.get('cookie') ?? '').split(';').map(part => part.trim().split('='));
@@ -63,44 +63,46 @@ export function createBrowserSessions(options) {
63
63
  },
64
64
  async join(request) {
65
65
  try {
66
- if (request.method !== 'POST' || request.url !== options.endpoint || request.headers.get('content-type')?.split(';')[0] !== 'application/x-www-form-urlencoded')
66
+ if (request.url !== options.endpoint)
67
67
  throw invalid();
68
- const fields = new URLSearchParams(await boundedText(request, 16384));
69
- if (fields.getAll('assertion').length !== 1 || [...fields.keys()].some(key => key !== 'assertion'))
68
+ if (request.method === 'GET') {
69
+ const code = randomBytes(32).toString('base64url'), cookie = randomBytes(32).toString('base64url');
70
+ 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: {
72
+ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store',
73
+ 'Content-Security-Policy': "default-src 'none'; form-action 'self'; frame-ancestors 'none'", 'Cross-Origin-Opener-Policy': 'same-origin',
74
+ 'Set-Cookie': '__Host-poppy_join=' + cookie + '; Path=/; Max-Age=60; Secure; HttpOnly; SameSite=Strict',
75
+ } });
76
+ }
77
+ if (request.method !== 'POST' || request.headers.get('content-type')?.split(';')[0] !== 'application/x-www-form-urlencoded')
70
78
  throw invalid();
71
- const jwt = fields.get('assertion');
72
- if (jwt.length > 8192)
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)))
73
84
  throw invalid();
74
- assertUniqueJwtMembers(jwt);
75
- const untrusted = decodeJwt(jwt);
76
- if (typeof untrusted.iss !== 'string')
85
+ const binding = issued[0];
86
+ const cookies = (request.headers.get('cookie') ?? '').split(';').map(part => part.trim().split('='));
87
+ 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)
77
89
  throw invalid();
78
- const client = await options.metadata.resolveClient(untrusted.iss);
79
- let keys = await options.metadata.resolveJwks(client.jwks_uri);
80
- let verified;
81
- try {
82
- verified = await compactVerify(jwt, createLocalJWKSet(keys), { algorithms: ['ES256', 'RS256'] });
83
- }
84
- catch (error) {
85
- if (!(error instanceof errors.JWKSNoMatchingKey))
86
- throw error;
87
- keys = await options.metadata.resolveJwks(client.jwks_uri, { refresh: true });
88
- verified = await compactVerify(jwt, createLocalJWKSet(keys), { algorithms: ['ES256', 'RS256'] });
89
- }
90
- const claims = object(parseJson(new TextDecoder().decode(verified.payload)));
91
- if (verified.protectedHeader.typ?.toLowerCase().replace(/^application\//, '') !== 'poppy-browser+jwt' || claims.iss !== client.client_id || claims.aud !== options.endpoint || typeof claims.sub !== 'string' || !claims.sub || claims.sub.length > 256 || typeof claims.session_id !== 'string' || !claims.session_id || typeof claims.jti !== 'string' || !claims.jti || claims.jti.length > 256 || !Number.isSafeInteger(claims.iat) || !Number.isSafeInteger(claims.exp) || claims.exp <= options.now() / 1000 || claims.iat > options.now() / 1000 + 30 || claims.exp <= claims.iat || claims.exp - claims.iat > 60 || typeof claims.return_to !== 'string')
90
+ 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
92
  throw invalid();
93
- const destination = secureUrl(claims.return_to);
94
- if (destination.hostname !== options.domain.toLowerCase() && !destination.hostname.endsWith(`.${options.domain.toLowerCase()}`))
93
+ const verified = await verifyDpopProof(fields.get('proof'), { method: 'POST', url: options.endpoint, nowMs: options.now() });
94
+ if (verified.claims.nonce !== codes[0])
95
95
  throw invalid();
96
- const session = await options.authority.join({ clientId: client.client_id, userId: claims.sub, sessionId: claims.session_id }, { jti: claims.jti, expiresAt: claims.exp * 1000 });
96
+ const session = await options.authority.join(verified.jkt);
97
97
  const seconds = Math.floor(Math.min(session.expiresAt - options.now(), lifetime * 1000) / 1000);
98
98
  if (seconds < 1)
99
99
  throw invalid();
100
100
  const value = randomBytes(32).toString('base64url');
101
101
  const { clientId, userId, sessionId, issuerGeneration } = session;
102
102
  await options.cookies.put(hash(value), { clientId, userId, sessionId, ...(issuerGeneration === undefined ? {} : { issuerGeneration }), expiresAt: options.now() + seconds * 1000 });
103
- return new Response(null, { status: 303, headers: { Location: claims.return_to, 'Set-Cookie': `${name}=${value}; Path=/; Secure; HttpOnly; SameSite=${sameSite}; Max-Age=${seconds}`, 'Cache-Control': 'no-store' } });
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 });
104
106
  }
105
107
  catch {
106
108
  return new Response('Invalid browser session', { status: 400, headers: { 'Cache-Control': 'no-store', 'Content-Type': 'text/plain' } });
@@ -1,4 +1,4 @@
1
- import type { ConversationEvent, JsonObject, Message, Sender, Status } from '../protocol/wire.js';
1
+ import type { ConversationEvent, JsonObject, Message, AuthorType, Status } from '../protocol/wire.js';
2
2
  import type { ConversationStore, ConversationNotifications } from './store.js';
3
3
  import type { AcceptedAuthority, AuthorityPolicy, RequestAuthenticator, WorkRevocationPolicy } from '../auth/authority.js';
4
4
  export interface ConversationRecord {
@@ -9,7 +9,7 @@ export interface ConversationRecord {
9
9
  bindingId: string;
10
10
  lastUserMessage?: Message;
11
11
  status: Status;
12
- responder: Sender;
12
+ responder: AuthorType;
13
13
  context: JsonObject;
14
14
  events: ConversationEvent[];
15
15
  eventWindow?: {
@@ -43,7 +43,7 @@ export interface MessageRecord {
43
43
  }
44
44
  export interface ConversationStoreState {
45
45
  cursorStatus?: 'expired' | 'invalid';
46
- version: 1;
46
+ version: 2;
47
47
  generation: string;
48
48
  conversations: Record<string, ConversationRecord>;
49
49
  bindings: Record<string, {
@@ -76,8 +76,8 @@ export interface ConversationOptions {
76
76
  authorizeOperator?: (operator: unknown, conversationId: string) => Promise<void>;
77
77
  /** Mandatory for requestAuthorization: the scopes the issuer's Direct Sign-In grants; work may ask only for these. */
78
78
  directScopes?: readonly string[];
79
- /** Scopes that allow reading account-bound history; default ['poppy:read']. A Company offering only custom scopes sets its own. */
80
- historyScopes?: readonly string[];
79
+ /** Required nonempty company scopes for account-bound history. */
80
+ historyScopes: readonly string[];
81
81
  }
82
82
  export interface WorkRef {
83
83
  workId: string;
@@ -93,7 +93,7 @@ export interface ConversationService {
93
93
  handle(request: Request): Promise<Response>;
94
94
  claim(): Promise<WorkClaim | null>;
95
95
  renew(work: WorkRef): Promise<void>;
96
- /** Conversation history for a model or handler; account-bound history needs the bound account and poppy:read. */
96
+ /** Conversation history for a model or handler; account-bound history needs the bound account and configured historyScopes. */
97
97
  history(work: WorkRef): Promise<ConversationEvent[]>;
98
98
  /** Account-use gate: call before any account read or tool. Checks accepted authority, then binds the shared ownership once. */
99
99
  requireAccount(work: WorkRef, scopes: readonly string[]): Promise<{
@@ -109,7 +109,7 @@ export interface ConversationService {
109
109
  handoff(work: WorkRef): Promise<void>;
110
110
  /** The company closes the conversation (and its open Direct Conversation); idempotent. */
111
111
  close(work: WorkRef): Promise<void>;
112
- /** A person joins: automated work is fenced and the returned claim publishes with sender human. Renew it within operatorLeaseMs. */
112
+ /** A person joins: automated work is fenced and the returned claim publishes with author.type human. Renew it within operatorLeaseMs. */
113
113
  joinHuman(operator: unknown, conversationId: string): Promise<WorkClaim>;
114
114
  /** Nobody can join a queued conversation: the agent says why and the conversation leaves the queue. No-op unless queued. */
115
115
  noHumanAvailable(operator: unknown, conversationId: string, reason: string): Promise<void>;