@poppykit/client 0.5.0 → 0.6.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 +9 -1
- package/AUTH.md +97 -90
- package/CHANGELOG.md +13 -1
- package/CONFORMANCE.md +73 -0
- package/CONTRIBUTING.md +12 -11
- package/DELIVERY.md +19 -3
- package/INTEGRATIONS.md +35 -23
- package/OPERATIONS.md +120 -0
- package/PROVIDER.md +20 -12
- package/README.md +6 -17
- package/RELEASING.md +2 -0
- package/SECURITY.md +2 -0
- package/STORAGE.md +14 -7
- package/dist/adapter-contract.d.ts +6 -0
- package/dist/adapter-contract.js +30 -0
- package/dist/auth/challenge.d.ts +2 -0
- package/dist/auth/challenge.js +8 -8
- package/dist/auth/client-auth.d.ts +20 -7
- package/dist/auth/client-auth.js +386 -334
- package/dist/auth/sqlite-store.js +13 -4
- package/dist/auth/store.d.ts +6 -1
- package/dist/conversation.d.ts +6 -0
- package/dist/conversation.js +18 -14
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/integrations.d.ts +11 -4
- package/dist/integrations.js +30 -38
- package/dist/network/discovery.d.ts +31 -13
- package/dist/network/discovery.js +103 -66
- package/dist/operations.d.ts +103 -0
- package/dist/operations.js +261 -0
- package/dist/postgres.d.ts +11 -0
- package/dist/postgres.js +40 -3
- package/dist/protocol/operations.d.ts +51 -0
- package/dist/protocol/operations.js +98 -0
- package/dist/protocol/session.d.ts +28 -0
- package/dist/protocol/session.js +93 -0
- package/dist/protocol/sse.d.ts +1 -0
- package/dist/protocol/sse.js +1 -1
- package/dist/protocol/wire.d.ts +8 -4
- package/dist/protocol/wire.js +37 -15
- package/dist/provider-postgres.js +3 -3
- package/dist/provider-storage.js +4 -4
- package/dist/provider.d.ts +26 -3
- package/dist/provider.js +94 -13
- package/dist/sqlite.d.ts +12 -0
- package/dist/sqlite.js +42 -0
- package/examples/operations.mjs +25 -0
- package/examples/quickstart.mjs +1 -0
- 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
|
|
84
|
-
|
|
85
|
-
the
|
|
86
|
-
and
|
|
87
|
-
|
|
88
|
-
|
|
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 (`
|
|
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.
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
146
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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;
|
|
59
|
-
|
|
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
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
25
|
-
|
|
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
|
|
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
|
|
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,7 @@ 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.
|
|
@@ -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;
|
package/dist/adapter-contract.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/auth/challenge.d.ts
CHANGED
|
@@ -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;
|
package/dist/auth/challenge.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
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
|
|
62
|
-
const scopes = [
|
|
63
|
-
const extensions = [
|
|
64
|
-
const safe = new Set(['
|
|
65
|
-
const error = [
|
|
66
|
-
const conversationId = [json.conversation_id,
|
|
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;
|