@poppykit/client 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/API.md +9 -1
  2. package/AUTH.md +97 -90
  3. package/CHANGELOG.md +20 -1
  4. package/CONFORMANCE.md +73 -0
  5. package/CONTRIBUTING.md +12 -11
  6. package/DELIVERY.md +19 -3
  7. package/INTEGRATIONS.md +35 -23
  8. package/OPERATIONS.md +120 -0
  9. package/PROVIDER.md +20 -12
  10. package/README.md +6 -17
  11. package/RELEASING.md +2 -0
  12. package/SECURITY.md +2 -0
  13. package/STORAGE.md +20 -7
  14. package/dist/adapter-contract.d.ts +6 -0
  15. package/dist/adapter-contract.js +30 -0
  16. package/dist/auth/challenge.d.ts +2 -0
  17. package/dist/auth/challenge.js +8 -8
  18. package/dist/auth/client-auth.d.ts +20 -7
  19. package/dist/auth/client-auth.js +386 -334
  20. package/dist/auth/sqlite-store.js +13 -4
  21. package/dist/auth/store.d.ts +6 -1
  22. package/dist/conversation.d.ts +6 -0
  23. package/dist/conversation.js +18 -14
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.js +2 -0
  26. package/dist/integrations.d.ts +11 -4
  27. package/dist/integrations.js +30 -38
  28. package/dist/network/discovery.d.ts +31 -13
  29. package/dist/network/discovery.js +103 -66
  30. package/dist/operations.d.ts +103 -0
  31. package/dist/operations.js +261 -0
  32. package/dist/postgres-runtime.js +12 -4
  33. package/dist/postgres.d.ts +11 -0
  34. package/dist/postgres.js +40 -3
  35. package/dist/protocol/operations.d.ts +51 -0
  36. package/dist/protocol/operations.js +98 -0
  37. package/dist/protocol/session.d.ts +28 -0
  38. package/dist/protocol/session.js +93 -0
  39. package/dist/protocol/sse.d.ts +1 -0
  40. package/dist/protocol/sse.js +1 -1
  41. package/dist/protocol/wire.d.ts +8 -4
  42. package/dist/protocol/wire.js +37 -15
  43. package/dist/provider-postgres.js +3 -3
  44. package/dist/provider-storage.js +4 -4
  45. package/dist/provider.d.ts +26 -3
  46. package/dist/provider.js +94 -13
  47. package/dist/sqlite.d.ts +12 -0
  48. package/dist/sqlite.js +42 -0
  49. package/examples/operations.mjs +25 -0
  50. package/examples/quickstart.mjs +1 -0
  51. package/package.json +5 -2
package/OPERATIONS.md ADDED
@@ -0,0 +1,120 @@
1
+ # Business operations (Operations v2)
2
+
3
+ Operations are optional on the wire and have no separate endpoint. Both verified
4
+ metadata documents must list `extensions.operations.version: "2"`. Publish support
5
+ only after configuring trusted consent, durable attempt storage and recovery for
6
+ every API, MCP and conversation interface your agent uses. Import `operationsV2`
7
+ for the metadata value; the SDK never advertises it automatically. OAuth consent,
8
+ provider enrollment, Company text and model output never approve business work.
9
+
10
+ `BusinessOperation` has `operation_id`, `revision`, `summary`, optional object
11
+ `terms`, and RFC3339 `expires_at`. `OperationApproval` names the exact ID/revision.
12
+ `parseBusinessOperation`, `parseOperationApproval`, `parseApprovalHeader`,
13
+ `approvalHeader`, `mcpApproval` and `approvalMetaKey` implement the wire contract.
14
+ Unknown JSON members survive; unknown valid Structured Field members are ignored.
15
+ The pinned RFC9651 parser has no runtime dependencies. The API body snapshot is
16
+ base64, preserving arbitrary bytes and the complete URL/query.
17
+
18
+ ## Trusted client
19
+
20
+ Configure `createOperationClient` with:
21
+
22
+ - `store`: `SqliteBusinessAttemptStore(path,namespace)`,
23
+ `PostgresBusinessAttemptStore(pool,namespace)`, or `BusinessAttemptStore`.
24
+ - `context`: `() => auth.businessContext(connectionId)`. This reads current
25
+ verified Company/client metadata and binds Company, User, client and local
26
+ authority. Signed-in contexts use the local grant identity; signed-out contexts
27
+ use the exact sid. Separate grants are conservatively distinct because OAuth
28
+ does not expose a portable account identifier. Share the same grant via
29
+ `start(domain,{accountFrom})` for an account continuation in another Session.
30
+ - `consent.confirm`: your trusted interface, outside model tools. Display the
31
+ Company name, exact summary and comparison differences. Treat all of them as
32
+ inert literal text, including terminal controls; render no links, images or
33
+ HTML. Collect a fresh actual User answer for each operation/revision. Only this
34
+ callback can transition a pending attempt to approved. Return `approve` or
35
+ `decline`; a thrown/interrupted callback never becomes approval.
36
+ - `checkTerms`: trusted comparison of the saved request, summary and structured
37
+ terms. Return discrepancies for the UI to display. The SDK cannot establish
38
+ the semantic agreement of arbitrary Company business JSON.
39
+ - `send`: `createOperationSender` with the actual channel integrations below.
40
+
41
+ Choose one stable local attempt ID per business action in trusted application
42
+ code. Keep that ID through revisions and recovery; do not let a model invent a
43
+ new attempt ID to bypass an unresolved action. A received revision is persisted
44
+ with its original request before asking the User. Authentication lifecycle
45
+ `OperationRecord`/`AuthOutcome.operationId`, provider operation IDs, conversation
46
+ message receipts and business attempt IDs are separate concepts/stores.
47
+
48
+ `receiveApi(id,originalRequest,response)` accepts a 403 `approval_required`.
49
+ `receiveMcp(id,{channel:'mcp',url,name,arguments},toolResult)` accepts
50
+ `isError:true` and `structuredContent.operation`.
51
+ `receiveConversation(id,{channel:'conversation',endpoint,conversationId},message)`
52
+ accepts only a recorded Company message's operation. None reports an action done.
53
+ `receive` is the lower-level equivalent for an already validated channel result.
54
+ It rejects changed terms under the same revision and replacing uncertain work.
55
+ A new revision before sending requires fresh consent and invalidates late UI
56
+ answers; previous decisions remain in the attempt.
57
+
58
+ Call `requestConsent(id)`, then explicitly `retry(id)`. There is no `approve` API
59
+ or unattended consent flag. Rejection, withdrawal and expiry are durable.
60
+ `get` reads a bound attempt. `recover(after?,limit=100)` pages pending IDs and
61
+ returns attempts or ownership errors without sending or approving anything.
62
+ After dismissing an interrupted UI, `resumeConsent` fences its late answer and
63
+ requires a new one. `withdraw` cancels a local dispatch through its AbortSignal.
64
+ Channel adapters must observe that signal before actual dispatch. After a send
65
+ may have started, withdrawal preserves uncertainty and suppresses later retries;
66
+ it does not claim to cancel the Company's effect.
67
+
68
+ `retry` commits uncertainty before sending. A crash, network error, authorization
69
+ failure, new operation on retry, or 503 retains the same request and approval.
70
+ Respect the stored `retryAt`; expiry after dispatch does not discard an uncertain
71
+ attempt. Do not ask for another operation until the old action's outcome is known.
72
+ Use `reconcile(id,lookup)` with trusted business-state lookup; a new operation is
73
+ not evidence of failure. Completed results cannot be overwritten by late sends.
74
+
75
+ ## Channel adapters
76
+
77
+ `createOperationSender({api,mcp,conversation})` preserves the saved channel:
78
+
79
+ - API: implement `api(request,context)` with `auth.apiFetch(connectionId,request,target,
80
+ scopes,context)` using the verified original target. `approvedApiRequest` repeats the
81
+ exact method, URL and bytes with `Poppy-Approval`. It drops the initial
82
+ Idempotency-Key; approved retries use the operation ID and no additional key.
83
+ Saved headers exclude credentials/cookies. Successful final status, headers and body
84
+ are stored as JSON (body base64); HTTP202 and non-success remain uncertain with Retry-After.
85
+ - MCP: implement `mcp(url,params,signal,context)` with your authenticated Streamable HTTP
86
+ transport. `approvedMcpParams` repeats the original tool/arguments with approval
87
+ in `params._meta['personalagentprotocol.org/approval']`. Preserve HTTP503 and
88
+ Retry-After as a pending delivery in custom transport adapters. An error tool
89
+ result remains uncertain, including a new operation.
90
+ - Conversation: configure `createConversationClient` with `operations:{client,
91
+ company}` from verified metadata. Both must advertise v2. Implement
92
+ `conversation(binding,message,signal,context)` with that client's `send` and original
93
+ conversation ID. `approvedConversationMessage` is approval-only and retains its
94
+ message ID on retry. Acceptance is pending, not completion; reconcile the later
95
+ business result. Event pages and SSE support operation-only Company messages.
96
+
97
+ Raw serialization helpers are trusted low-level APIs; exposing them as model
98
+ approval tools would violate the consent contract. Account credentials remain
99
+ inside the authentication lifecycle, never in consent inputs or model messages.
100
+
101
+ ## Persistence and migration
102
+
103
+ Attempt format is version1, separate from auth v3. SQLite creates the additive
104
+ `business_attempts(ns,id,body)` table. PostgreSQL uses existing
105
+ `poppy_client.records`, kind `business_attempts`; no applied SQL migration changes.
106
+ Existing rows, grants and pending/uncertain work are untouched. Use a private
107
+ namespace per local user/client and protect stored requests/terms/results as
108
+ personal data. Custom adapters serialize absent-row races across processes,
109
+ reject async callbacks/non-JSON/unknown versions, roll back on exceptions, return
110
+ detached snapshots and page nonterminal IDs in ascending binary order. Run
111
+ `runBusinessAttemptAdapterContract` from the adapter-contract subpath. Never prune
112
+ uncertain attempts. Explicit application retention may archive terminal decisions
113
+ only after recovery references no longer need them; storage removal is not remote
114
+ cancellation. Bundled adapters preserve all decisions by default.
115
+
116
+ Run `node examples/operations.mjs --self-check` for a local synthetic flow that
117
+ declines without dispatch. Without the flag it asks through a terminal interface.
118
+ See tests/operations.test.mts for independent-wire and real persistent cases.
119
+
120
+ Every sender callback receives the original `BusinessClientContext`. Pass it as the final `expectedContext` argument to `auth.apiFetch` or `auth.authorizedFetch`, including conversation approval dispatch. The authentication lifecycle checks it through metadata/credential waits and retries, so a changed Session/account cannot silently supply replacement authority. Custom MCP transports must pin that same context through their final dispatch.
package/PROVIDER.md CHANGED
@@ -80,12 +80,13 @@ const provider = createProvider({
80
80
  // await provider.handle(request)
81
81
  ```
82
82
 
83
- `operatorKeys`, `operatorLogin`, `operatorSessionVerifier` and
84
- `verifiedCompanyResolver` are operator-owned adapters, not globals supplied by
85
- the package. A session verifier must independently verify both the provider client
86
- and derived UserID; an opaque session ID alone does not prove ownership. Browser
87
- signing fails closed without that verifier. Starting a session signs a derived
88
- UserID assertion; the company checks session/client/UserID association on renewals.
83
+ `operatorKeys`, `operatorLogin` and `verifiedCompanyResolver` are operator-owned
84
+ adapters. A new enrollment receives a unique HTTPS client metadata URL beneath
85
+ the configured clientId path. Serve that exact URL through provider.handle; the
86
+ shared JWKS and callback remain unchanged. A shared origin does not make those
87
+ client IDs interchangeable. Existing shared-client enrollments are retained for
88
+ legacy assertion/cleanup, but cannot receive delegations; obtain fresh explicit
89
+ enrollment rather than transferring old account grants to a different client ID.
89
90
 
90
91
  POST JSON operations use `version: 1` and `installationId`. Enroll additionally
91
92
  takes a fresh `idempotencyKey`; private operations carry `Authorization: Bearer
@@ -98,11 +99,14 @@ but cannot recover another installation's result. Enrollment never accepts calle
98
99
  subjects, arbitrary audiences, lifetimes, key IDs, raw bytes or signing claims.
99
100
 
100
101
  `identity` takes domain and exact issuer; `begin` registers immutable operation,
101
- issuer/domain, purpose (`session`, `client-authentication`, `direct`) and Direct Session Key binding. Company metadata is independently
102
+ issuer/domain, purpose (`client-authentication`, `direct`) and the Direct OAuth DPoP thumbprint (or explicit null for an unbound Bearer code). Company metadata is independently
102
103
  resolved and enrollment company policy checked. `assert` takes operation/purpose
103
- only and constructs a 60-second assertion with fresh 256-bit jti. Browser-purpose
104
- operations/assertions and verifySession are removed; browser joining uses the
105
- locally held Session Key and browser-bound form code. `createProviderClient` binds
104
+ only and constructs a 60-second assertion with fresh 256-bit jti. New Sessions use `delegate({operationId,jwk})`: an enrollment restricted to one
105
+ Company can obtain a cnf-only ES256 delegation with coarse half-hour issuance and a one-hour signed lifetime for a public P-256 key.
106
+ The payload contains no User/Company claims. Legacy shared-client and multi-Company
107
+ enrollments are refused. Delegation results are encrypted for idempotent delivery,
108
+ expired results/tombstones are pruned before the 1000-operation cap. Browser joining
109
+ uses separate code authorization with the finalized Session JWT. `createProviderClient` binds
106
110
  enrollment credentials and automatically registers SDK assertion operations.
107
111
 
108
112
  Direct sign-in uses SDK-generated 256-bit state. Trusted CLI code privately
@@ -142,8 +146,8 @@ expires plus 30-second skew; never publish private JWK fields. The operator owns
142
146
  the publication grace period and KMS key lifecycle.
143
147
 
144
148
  Enrollment revocation stops new signing/retrieval and cancels pending results.
145
- It cannot invalidate delivered assertions, existing company Session Tokens,
146
- Account Tokens or DPoP keys. Revoke company authorization **before** disabling
149
+ It cannot invalidate delivered assertions, existing delegated Session JWTs,
150
+ OAuth account credentials or DPoP keys. Revoke company authorization **before** disabling
147
151
  provider signing, or use authenticated company account controls afterward.
148
152
  This is scoped authority, redaction and provider key custody; it does not isolate
149
153
  authority from unrestricted programs running as the same OS user.
@@ -170,3 +174,7 @@ and cannot turn local synchronous I/O into remote persistence. New remote/custom
170
174
  stores implement the targeted contract directly. The bundled Postgres adapter is
171
175
  `PostgresProviderStore` from `@poppykit/client/provider/postgres`; see
172
176
  [storage](STORAGE.md) for explicit migrations, TLS and lifecycle.
177
+
178
+ ## Operations capability metadata
179
+
180
+ Provider authentication/callback operations are not business operations. Optional `operations:{ready(enrollmentId)}` publishes `extensions.operations.version:"2"` only for that isolated enrollment when its complete agent consent, all used channels and durable recovery are installed. Omission/false publishes nothing. The legacy shared client document never advertises v2. Readiness must reflect host configuration, never Company/model claims or enrollment/OAuth approval. [OPERATIONS.md](OPERATIONS.md) defines the separate business contract.
package/README.md CHANGED
@@ -1,23 +1,10 @@
1
1
  # PoppyKit client
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
-
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`.
9
-
10
- TypeScript SDK for [Personal Agent Protocol draft 0.3](https://personalagentprotocol.org/docs/0.3/spec):
11
- discovery, authentication, conversations and API/browser/feedback hooks. Your
12
- application supplies the agent and trusted infrastructure. [MIT](LICENSE).
3
+ TypeScript SDK for [Personal Agent Protocol draft 0.3](https://personalagentprotocol.org/docs/0.3/spec).
4
+ Discover companies, authenticate and manage conversations. [MIT](LICENSE).
13
5
 
14
6
  ## Install
15
7
 
16
- Use the exact version verified by its protected release workflow; check registry publication before installation.
17
-
18
- Requires Node **24.x, at least 24.21.0**, ESM and HTTPS. CI tests Node 24.21.0 on Linux, macOS and
19
- Windows; other Node majors are unsupported.
20
-
21
8
  ```sh
22
9
  npm install @poppykit/client
23
10
  ```
@@ -44,6 +31,8 @@ without a network service.
44
31
 
45
32
  ## Guides
46
33
 
34
+ - [Business operations](OPERATIONS.md): exact per-operation approval, durable retries and effect contracts.
35
+
47
36
  - [Authentication](AUTH.md): sessions, Direct/Device/Mediated, consent, renewal and revocation.
48
37
  - [Storage](STORAGE.md): targeted adapters, secret references, recovery and explicit resets.
49
38
  - [Delivery](DELIVERY.md): messages, streaming, cancellation, retries and retention.
@@ -55,5 +44,5 @@ without a network service.
55
44
  Keep Account Tokens, DPoP keys and PKCE in a secure SecretStore; never expose them
56
45
  to model tools. An uncertain remote result must not be silently replayed. SQLite
57
46
  is optional/experimental and is not vault encryption. Production issuers, vaults,
58
- browsers and hosting require operator validation; individual extensions and
59
- application business workflows are outside this SDK.
47
+ browsers and hosting require operator validation; Mediated Sign-In v1 is supported. Application business policy belongs to the host;
48
+ see [protocol boundaries](CONFORMANCE.md).
package/RELEASING.md CHANGED
@@ -89,3 +89,5 @@ certify a production keyring, physical terminal, browser engine, KMS or TLS depl
89
89
  Windows GNU tar is given archive basename plus directory cwd to avoid drive-colon
90
90
  remote parsing. Failure logs retain seven days. Expired/missing artifacts require a
91
91
  new full run, not relabeling old results. See [CONTRIBUTING.md](CONTRIBUTING.md).
92
+
93
+ The local Operations SDK checkpoint does not satisfy release acceptance. Complete the block3 CLI business-approval experience and stable all-tests/package/native gates before publication. Operations metadata must remain opt-in to fully configured hosts. Include OPERATIONS.md and both operation examples in candidate archives.
package/SECURITY.md CHANGED
@@ -11,3 +11,5 @@ impact and the trust boundary involved. These draft packages have no promised
11
11
  support window or response SLA. Custom vaults, issuers, browser controllers,
12
12
  proxy routing and deployment policy remain operator responsibilities. Redaction
13
13
  and scoped authority do not isolate an unrestricted same-user program.
14
+
15
+ Business-operation approval requires the trusted per-operation UI described in [OPERATIONS.md](OPERATIONS.md). Keep Company/model text separate from user consent. Local effects must share the receipt transaction; arbitrary network effects require host idempotency and reconciliation. Retain uncertain claims until the actual outcome is known.
package/STORAGE.md CHANGED
@@ -14,15 +14,17 @@ const conversationStore = new SqliteConversationStore('/private/path/messages.db
14
14
  ```
15
15
 
16
16
  Use one auth database per namespace/local user/client identity. The owner tuple is
17
- checked on every SDK auth read and mutation. Identities are keyed by the exact full
18
- issuer, connections by immutable IDs, operations by ID and callback state hash,
17
+ checked on every SDK auth read and mutation. Identities are keyed by the exact Company
18
+ identifier, connections by immutable IDs, operations by ID and callback state hash,
19
19
  cleanup by connection, and token slots by session/resource. Secret values are never
20
20
  stored in auth records: immutable references point to the host's `SecretStore`.
21
- Session Tokens remain in process memory. Conversation databases contain message
21
+ Access tokens remain in process memory; Session JWTs are freshly signed. Conversation databases contain message
22
22
  bodies; operators must provide private storage and their required encryption.
23
23
 
24
- Auth selections load an identity or a connection aggregate (its grants, operations,
25
- and cleanup), never unrelated connections. Operation/callback selectors resolve the
24
+ Auth selections load an identity or a connection aggregate, including all peers
25
+ referencing its shared grants and their operations/cleanup. An `account: {issuer,
26
+ userId}` selector loads every connection for that Company and pairwise User, its
27
+ identity, grants, operations and cleanup; account-wide sign-out is one transaction. Operation/callback selectors resolve the
26
28
  owning connection through indexes. `list` explicitly enumerates connections.
27
29
  Recovery pages nonterminal operation IDs in batches of 100, then rechecks each
28
30
  operation and its owner inside its transaction. `cleanup(before, limit)` removes
@@ -88,14 +90,15 @@ recovery of a token that the issuer rotated but never delivered.
88
90
 
89
91
  ## Development reset
90
92
 
91
- Normalized auth state version 1 upgrades to version 2 atomically on first access
93
+ Normalized auth state versions 1 and 2 upgrade to version 3 atomically on first access
92
94
  in both SQLite and PostgreSQL, under the namespace's writer lock. Stop old SDK
93
95
  processes before opening the candidate adapters; mixed-version writers are not
94
96
  supported. This is a record upgrade, with no SQL schema change. Owner, epoch,
95
97
  stable User IDs, grants, scopes, cleanup and internal connection/operation IDs stay.
96
98
  Legacy connections discard their volatile token slots and require a fresh key on
97
99
  next renewal. Old shared-key references remain retained, but are never used for
98
- another token request. Prepared old operations are cancelled; dispatched/staged
100
+ another token request. Prepared old operations are cancelled and private verifier/device references are
101
+ queued for cleanup before history can be pruned; dispatched/staged
99
102
  ones still pass through `recover()`, preserving rotated tokens and uncertainty.
100
103
  Custom targeted adapters must provide the same complete-namespace upgrade under
101
104
  their writer lock; the client's fallback also upgrades whole-state snapshots.
@@ -183,3 +186,13 @@ transaction handle. It uses the supplied handle rather than its configured pool;
183
186
  the host owns the entire commit/rollback. Savepoints protect individual provider
184
187
  operations, while the host must abort the outer transaction on failure. Never
185
188
  span model/network I/O. See the [provider example](PROVIDER.md).
189
+
190
+ ## Business operation persistence
191
+
192
+ [Operations storage contracts](OPERATIONS.md#persistence-and-migration) are separate from authentication lifecycle operations and message receipts. BusinessAttemptStore uses a synchronous transaction callback and an ascending nonterminal `pending` selector. SQLite adds a separate business_attempts table; PostgreSQL uses a separate record kind. No existing SQL history, credentials or uncertain work is deleted. Unknown versions fail closed. External effects require a durable downstream idempotency/reconciliation contract. Never prune or release an uncertain business claim after a worker deadline.
193
+
194
+ Postgres adapters handle socket errors while they own a checked-out connection.
195
+ Connection loss invalidates the transaction, rejects the operation and discards
196
+ the broken connection. A successful retry must reconcile durable state first;
197
+ a lost COMMIT response does not prove rollback. Callers still own their pool and
198
+ its idle `error` handler, and host-enlisted connection lifetimes.
@@ -13,3 +13,9 @@ export type ProviderAdapterFixture = () => Promise<{
13
13
  /** Run against two handles sharing a fresh namespace; real engine/process tests remain the host's responsibility. */
14
14
  export declare function runClientConversationAdapterContract(fixture: ClientConversationAdapterFixture): void;
15
15
  export declare function runProviderAdapterContract(fixture: ProviderAdapterFixture): void;
16
+ export type BusinessAttemptAdapterFixture = () => Promise<{
17
+ store: import('./operations.js').BusinessAttemptStore;
18
+ second: import('./operations.js').BusinessAttemptStore;
19
+ close(): Promise<void>;
20
+ }>;
21
+ export declare function runBusinessAttemptAdapterContract(fixture: BusinessAttemptAdapterFixture): void;
@@ -46,3 +46,33 @@ export function runProviderAdapterContract(fixture) {
46
46
  }
47
47
  });
48
48
  }
49
+ export function runBusinessAttemptAdapterContract(fixture) {
50
+ test('business attempt adapter contract: serial consent claims, detached recovery, rollback and version fences', async () => {
51
+ const f = await fixture();
52
+ try {
53
+ const record = { version: 1, id: 'action', context: { companyId: 'https://company.example', companyName: 'Company', clientId: 'https://agent.example/client', userId: 'user', authority: 'session:sid', operations: true }, request: { channel: 'conversation', endpoint: 'https://company.example/conversations', conversationId: 'conversation' }, operation: { operation_id: 'operation', revision: 'revision', summary: 'Cancel booking', expires_at: '2030-01-01T00:00:00Z' }, phase: 'pending_consent', messageId: 'message' };
54
+ await f.store.transaction(record.id, slot => { slot.record = record; });
55
+ let claims = 0;
56
+ await Promise.all([f.store, f.second].map(store => store.transaction(record.id, slot => { if (slot.record.phase === 'pending_consent') {
57
+ slot.record.phase = 'consenting';
58
+ claims++;
59
+ } })));
60
+ assert.equal(claims, 1);
61
+ assert.deepEqual(await f.second.pending(undefined, 10), ['action']);
62
+ const read = () => f.store.transaction(record.id, slot => structuredClone(slot.record));
63
+ const value = await read();
64
+ value.operation.summary = 'mutated';
65
+ assert.equal((await read()).operation.summary, 'Cancel booking');
66
+ await assert.rejects(f.store.transaction(record.id, slot => { slot.record.phase = 'approved'; throw Error('rollback'); }), /rollback/);
67
+ await assert.rejects(f.store.transaction(record.id, async (slot) => { slot.record.phase = 'approved'; }));
68
+ await assert.rejects(f.store.transaction(record.id, slot => { Object.assign(slot.record, { version: 99 }); }), /binding|version/);
69
+ assert.equal((await read()).phase, 'consenting');
70
+ for (const id of ['A', 'Z', '_', 'a'])
71
+ await f.store.transaction(id, slot => { slot.record = { ...record, id }; });
72
+ assert.deepEqual(await f.second.pending('Z', 10), ['_', 'a', 'action']);
73
+ }
74
+ finally {
75
+ await f.close();
76
+ }
77
+ });
78
+ }
@@ -3,12 +3,14 @@ export declare function schemeChallenge(header: string | null, scheme: 'DPoP' |
3
3
  scope?: string;
4
4
  extension?: string;
5
5
  conversation_id?: string;
6
+ resource_metadata?: string;
6
7
  } | undefined;
7
8
  export declare const dpopChallenge: (header: string | null) => {
8
9
  error?: string;
9
10
  scope?: string;
10
11
  extension?: string;
11
12
  conversation_id?: string;
13
+ resource_metadata?: string;
12
14
  } | undefined;
13
15
  export interface AuthChallenge {
14
16
  error?: string;
@@ -1,4 +1,4 @@
1
- // Reads the DPoP challenge from a WWW-Authenticate header (RFC 9110 11.6.1, spec 6); other schemes' params are ignored.
1
+ // Read only the requested scheme; commas inside quoted attributes are not challenge boundaries.
2
2
  const TOKEN = /[A-Za-z0-9!#$%&'*+.^_`|~-]+/y;
3
3
  export function schemeChallenge(header, scheme) {
4
4
  if (!header)
@@ -50,7 +50,7 @@ export function schemeChallenge(header, scheme) {
50
50
  i += value.length;
51
51
  }
52
52
  const name = match[0].toLowerCase();
53
- if (current.scheme === scheme.toLowerCase() && ['error', 'scope', 'extension', 'conversation_id'].includes(name))
53
+ if (current.scheme === scheme.toLowerCase() && ['error', 'scope', 'extension', 'conversation_id', 'resource_metadata'].includes(name))
54
54
  current.params[name] = value;
55
55
  }
56
56
  return found;
@@ -58,11 +58,11 @@ export function schemeChallenge(header, scheme) {
58
58
  export const dpopChallenge = (header) => schemeChallenge(header, 'DPoP');
59
59
  // Combine independent detail channels: a sparse JSON error must not erase header scopes/extensions.
60
60
  export function authChallenge(header, body) {
61
- const challenge = dpopChallenge(header), json = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
62
- const scopes = [challenge?.scope, json.scope].flatMap(value => typeof value === 'string' ? value.split(' ').filter(scope => /^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope)) : []);
63
- const extensions = [challenge?.extension, json.extension].filter((value) => typeof value === 'string' && value.length <= 512 && /^(?:[A-Za-z0-9._-]+|(?:[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\.)+[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*)$/.test(value));
64
- const safe = new Set(['sign_in_required', 'insufficient_scope', 'invalid_token', 'invalid_dpop_proof', 'use_dpop_nonce', 'extension_required', 'rate_limited', 'invalid_scope']);
65
- const error = [challenge?.error, json.error].find((value) => typeof value === 'string' && safe.has(value));
66
- const conversationId = [json.conversation_id, challenge?.conversation_id].find((value) => typeof value === 'string' && value.length > 0 && value.length <= 256);
61
+ const preferred = dpopChallenge(header) ?? schemeChallenge(header, 'Bearer'), challenges = preferred ? [preferred] : [], json = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
62
+ const scopes = [...challenges.map(value => value.scope), json.scope].flatMap(value => typeof value === 'string' ? value.split(' ').filter(scope => /^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope)) : []);
63
+ const extensions = [...challenges.map(value => value.extension), json.extension].filter((value) => typeof value === 'string' && value.length <= 512 && /^(?:[A-Za-z0-9._-]+|(?:[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\.)+[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*)$/.test(value));
64
+ const safe = new Set(['insufficient_scope', 'invalid_token', 'invalid_dpop_proof', 'use_dpop_nonce', 'extension_required', 'rate_limited', 'invalid_scope', 'invalid_session']);
65
+ const error = [...challenges.map(value => value.error), json.error].find((value) => typeof value === 'string' && safe.has(value));
66
+ const conversationId = [json.conversation_id, ...challenges.map(value => value.conversation_id)].find((value) => typeof value === 'string' && value.length > 0 && value.length <= 256);
67
67
  return { ...(error ? { error } : {}), requiredScopes: [...new Set(scopes)], extensions: [...new Set(extensions)], ...(conversationId ? { conversationId } : {}) };
68
68
  }
@@ -1,3 +1,4 @@
1
+ import type { BusinessClientContext } from '../operations.js';
1
2
  import type { Alternative, ExcludedTask } from '../network/tasks.js';
2
3
  import type { AuthSelection, AuthStore } from './store.js';
3
4
  import type { OperationOwner } from './ownership.js';
@@ -19,16 +20,20 @@ export interface IdentitySigner {
19
20
  export type AssertionRequest = {
20
21
  operationId: string;
21
22
  issuer: string;
22
- } & ({
23
23
  purpose: 'client-authentication';
24
- } | {
25
- purpose: 'session';
26
- });
24
+ };
27
25
  export interface AssertionProvider {
28
26
  assert(request: AssertionRequest): Promise<{
29
27
  assertion: string;
30
28
  expiresAt: number;
31
29
  }>;
30
+ delegate?(request: {
31
+ operationId: string;
32
+ jwk: JsonObject;
33
+ }): Promise<{
34
+ delegation: string;
35
+ expiresAt: number;
36
+ }>;
32
37
  }
33
38
  export interface ProviderIdentity {
34
39
  identity(issuer: string): Promise<{
@@ -147,9 +152,11 @@ export interface ConversationTarget {
147
152
  export interface ClientAuth {
148
153
  start(domain: string, input?: {
149
154
  anonymous?: boolean;
155
+ accountFrom?: string;
150
156
  }): Promise<AuthOutcome>;
151
157
  get(connectionId: string): Promise<Connection | null>;
152
158
  list(): Promise<Connection[]>;
159
+ businessContext(connectionId: string): Promise<BusinessClientContext>;
153
160
  target(connectionId: string): Promise<ConversationTarget>;
154
161
  authorization(connectionId: string, requiredScopes: readonly string[]): Promise<AuthorizationRequirement>;
155
162
  beginSignIn(connectionId: string, input: SignInInput): Promise<SignInStart>;
@@ -167,9 +174,9 @@ export interface ClientAuth {
167
174
  signOut(connectionId: string): Promise<SignOutOutcome>;
168
175
  retryCleanup(connectionId: string): Promise<SignOutOutcome>;
169
176
  recover(): Promise<AuthOutcome[]>;
170
- authorizedFetch(connectionId: string, request: Request, target: ConversationTarget, requiredScopes?: readonly string[]): Promise<Response>;
177
+ authorizedFetch(connectionId: string, request: Request, target: ConversationTarget, requiredScopes?: readonly string[], expectedContext?: BusinessClientContext): Promise<Response>;
171
178
  apiTarget(connectionId: string, apiUrl: string, destination?: string): Promise<ApiTarget>;
172
- apiFetch(connectionId: string, request: Request, target: ApiTarget, requiredScopes?: readonly string[]): Promise<Response>;
179
+ apiFetch(connectionId: string, request: Request, target: ApiTarget, requiredScopes?: readonly string[], expectedContext?: BusinessClientContext): Promise<Response>;
173
180
  joinBrowser(connectionId: string, returnTo: string, controller: AgentBrowserController): Promise<void>;
174
181
  feedback(connectionId: string, report: FeedbackReport): Promise<Alternative | void>;
175
182
  excludedTasks(connectionId: string): Promise<ExcludedTask[]>;
@@ -197,6 +204,12 @@ export interface ConnectionRecord {
197
204
  anonymous?: boolean;
198
205
  notBefore?: string;
199
206
  needsSessionKey?: boolean;
207
+ sessionSigning?: {
208
+ keyRef: string;
209
+ delegationRef: string;
210
+ expiresAt: number;
211
+ };
212
+ signingCleanup?: string[];
200
213
  }
201
214
  export interface GrantRecord {
202
215
  id: string;
@@ -271,7 +284,7 @@ export interface CleanupRecord {
271
284
  notBefore?: string;
272
285
  }
273
286
  export interface ClientAuthState {
274
- version: 1 | 2;
287
+ version: 1 | 2 | 3;
275
288
  epoch: string;
276
289
  owner: {
277
290
  namespace: string;