@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 +1 -0
- package/AUTH.md +36 -7
- package/CHANGELOG.md +6 -0
- package/CONTRIBUTING.md +5 -3
- package/DELIVERY.md +19 -1
- package/INTEGRATIONS.md +21 -11
- package/README.md +9 -3
- package/STORAGE.md +16 -0
- package/dist/adapter-contract.js +11 -11
- package/dist/browser.d.ts +9 -6
- package/dist/browser.js +32 -30
- package/dist/conversation/service.d.ts +7 -7
- package/dist/conversation/service.js +26 -24
- package/dist/index.d.ts +1 -0
- package/dist/network/discovery.d.ts +20 -9
- package/dist/network/discovery.js +74 -21
- package/dist/network/redirect.d.ts +3 -0
- package/dist/network/redirect.js +38 -0
- package/dist/network/tasks.d.ts +23 -0
- package/dist/network/tasks.js +45 -0
- package/dist/postgres.js +22 -4
- package/dist/protocol/wire.d.ts +12 -11
- package/dist/protocol/wire.js +33 -7
- package/dist/reference-auth/index.d.ts +16 -5
- package/dist/reference-auth/index.js +130 -59
- package/dist/reference-auth/store.d.ts +2 -1
- package/dist/sqlite.js +22 -4
- package/examples/quickstart.mjs +2 -1
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
4
|
-
and [guides](https://personalagentprotocol.org/docs/0.
|
|
5
|
-
supporting evidence.
|
|
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
|
-
|
|
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
|
|
31
|
-
|
|
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
|
-
|
|
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.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/adapter-contract.js
CHANGED
|
@@ -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: ['
|
|
31
|
-
account2: { ...base, accountId: 'acct_2', scopes: ['
|
|
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',
|
|
34
|
-
const reply = { id: 'msg_r1',
|
|
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: ['
|
|
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, ['
|
|
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',
|
|
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, ['
|
|
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: '
|
|
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}`, ['
|
|
159
|
-
await b.requestAuthorization(work, `auth_${outputId}`, ['
|
|
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(
|
|
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 {
|
|
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.
|
|
66
|
+
if (request.url !== options.endpoint)
|
|
67
67
|
throw invalid();
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
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
|
|
79
|
-
|
|
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
|
|
94
|
-
if (
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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:
|
|
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
|
-
/**
|
|
80
|
-
historyScopes
|
|
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
|
|
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
|
|
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>;
|