@poppykit/client 0.4.1 → 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 +12 -3
- package/AUTH.md +97 -65
- package/CHANGELOG.md +18 -0
- package/CONFORMANCE.md +73 -0
- package/CONTRIBUTING.md +13 -10
- package/DELIVERY.md +30 -1
- package/INTEGRATIONS.md +63 -21
- package/OPERATIONS.md +120 -0
- package/PROVIDER.md +24 -14
- package/README.md +7 -13
- package/RELEASING.md +2 -0
- package/SECURITY.md +2 -0
- package/STORAGE.md +24 -5
- package/dist/adapter-contract.d.ts +6 -0
- package/dist/adapter-contract.js +31 -1
- package/dist/auth/challenge.d.ts +2 -0
- package/dist/auth/challenge.js +8 -8
- package/dist/auth/client-auth.d.ts +29 -13
- package/dist/auth/client-auth.js +498 -318
- package/dist/auth/sqlite-store.js +27 -4
- package/dist/auth/store.d.ts +6 -1
- package/dist/conversation.d.ts +6 -0
- package/dist/conversation.js +28 -14
- package/dist/index.d.ts +6 -1
- package/dist/index.js +3 -1
- package/dist/integrations.d.ts +16 -4
- package/dist/integrations.js +30 -38
- package/dist/network/discovery.d.ts +51 -22
- package/dist/network/discovery.js +159 -69
- 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/operations.d.ts +103 -0
- package/dist/operations.js +261 -0
- package/dist/postgres.d.ts +11 -0
- package/dist/postgres.js +52 -2
- 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 +20 -15
- package/dist/protocol/wire.js +70 -22
- package/dist/provider-postgres.js +3 -3
- package/dist/provider-storage.js +4 -4
- package/dist/provider.d.ts +26 -10
- package/dist/provider.js +103 -32
- package/dist/sqlite.d.ts +12 -0
- package/dist/sqlite.js +42 -0
- package/examples/operations.mjs +25 -0
- package/examples/quickstart.mjs +2 -1
- 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
|
@@ -75,18 +75,18 @@ const provider = createProvider({
|
|
|
75
75
|
const human = await operatorLogin.authenticateEnrollment(request);
|
|
76
76
|
return human ? { subject: human.stableSubject, allowedCompanies: human.companyDomains } : null;
|
|
77
77
|
},
|
|
78
|
-
verifySession: binding => operatorSessionVerifier.owns(binding),
|
|
79
78
|
});
|
|
80
79
|
// Route Fetch Requests under /v1/{operation}, plus the exact public URLs above:
|
|
81
80
|
// await provider.handle(request)
|
|
82
81
|
```
|
|
83
82
|
|
|
84
|
-
`operatorKeys`, `operatorLogin
|
|
85
|
-
|
|
86
|
-
the
|
|
87
|
-
and
|
|
88
|
-
|
|
89
|
-
|
|
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.
|
|
90
90
|
|
|
91
91
|
POST JSON operations use `version: 1` and `installationId`. Enroll additionally
|
|
92
92
|
takes a fresh `idempotencyKey`; private operations carry `Authorization: Bearer
|
|
@@ -99,11 +99,14 @@ but cannot recover another installation's result. Enrollment never accepts calle
|
|
|
99
99
|
subjects, arbitrary audiences, lifetimes, key IDs, raw bytes or signing claims.
|
|
100
100
|
|
|
101
101
|
`identity` takes domain and exact issuer; `begin` registers immutable operation,
|
|
102
|
-
issuer/domain, purpose (`
|
|
103
|
-
optional session and browser return destination. 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
|
|
104
103
|
resolved and enrollment company policy checked. `assert` takes operation/purpose
|
|
105
|
-
only and constructs a 60-second assertion with fresh 256-bit jti.
|
|
106
|
-
|
|
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
|
|
107
110
|
enrollment credentials and automatically registers SDK assertion operations.
|
|
108
111
|
|
|
109
112
|
Direct sign-in uses SDK-generated 256-bit state. Trusted CLI code privately
|
|
@@ -112,7 +115,10 @@ authorization URL. Only its hash is indexed. The callback accepts state, exact
|
|
|
112
115
|
issuer, and exactly one code/error; result ciphertext is sealed atomically with
|
|
113
116
|
the immutable binding. Identical pending callbacks are harmless; conflicting,
|
|
114
117
|
canceled, expired and consumed callbacks fail. `takeResult` requires enrollment,
|
|
115
|
-
installation, operation, issuer and exact
|
|
118
|
+
installation, operation, issuer and exact `sessionKey` thumbprint from the SDK's
|
|
119
|
+
authorization URL `dpop_jkt`; the local `Connection.sessionId` cannot identify a
|
|
120
|
+
company Session. Old Direct operations without the key binding must restart.
|
|
121
|
+
Retrieval consumes within the same
|
|
116
122
|
storage transaction before returning private callback data. Lost retrieval delivery
|
|
117
123
|
is uncertain, never a reason to exchange or retrieve again. SDK code owns PKCE and
|
|
118
124
|
the exchange. Never log request URLs or responses on this route.
|
|
@@ -140,8 +146,8 @@ expires plus 30-second skew; never publish private JWK fields. The operator owns
|
|
|
140
146
|
the publication grace period and KMS key lifecycle.
|
|
141
147
|
|
|
142
148
|
Enrollment revocation stops new signing/retrieval and cancels pending results.
|
|
143
|
-
It cannot invalidate delivered assertions, existing
|
|
144
|
-
|
|
149
|
+
It cannot invalidate delivered assertions, existing delegated Session JWTs,
|
|
150
|
+
OAuth account credentials or DPoP keys. Revoke company authorization **before** disabling
|
|
145
151
|
provider signing, or use authenticated company account controls afterward.
|
|
146
152
|
This is scoped authority, redaction and provider key custody; it does not isolate
|
|
147
153
|
authority from unrestricted programs running as the same OS user.
|
|
@@ -168,3 +174,7 @@ and cannot turn local synchronous I/O into remote persistence. New remote/custom
|
|
|
168
174
|
stores implement the targeted contract directly. The bundled Postgres adapter is
|
|
169
175
|
`PostgresProviderStore` from `@poppykit/client/provider/postgres`; see
|
|
170
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,18 +1,10 @@
|
|
|
1
1
|
# PoppyKit client
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
TypeScript SDK for [Personal Agent Protocol draft 0.2](https://personalagentprotocol.org/docs/0.2/spec):
|
|
6
|
-
discovery, authentication, conversations and API/browser/feedback hooks. Your
|
|
7
|
-
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).
|
|
8
5
|
|
|
9
6
|
## Install
|
|
10
7
|
|
|
11
|
-
This source contains unreleased repairs; final version selection and publication are pending.
|
|
12
|
-
|
|
13
|
-
Requires Node **24.x, at least 24.21.0**, ESM and HTTPS. CI tests Node 24.21.0 on Linux, macOS and
|
|
14
|
-
Windows; other Node majors are unsupported.
|
|
15
|
-
|
|
16
8
|
```sh
|
|
17
9
|
npm install @poppykit/client
|
|
18
10
|
```
|
|
@@ -20,7 +12,7 @@ npm install @poppykit/client
|
|
|
20
12
|
## Discover a company
|
|
21
13
|
|
|
22
14
|
No credentials or database are needed. Replace `company.example` with a company
|
|
23
|
-
publishing draft 0.
|
|
15
|
+
publishing draft 0.3 discovery:
|
|
24
16
|
|
|
25
17
|
```js
|
|
26
18
|
import { createMetadataResolver, createNodeTransport } from '@poppykit/client';
|
|
@@ -39,6 +31,8 @@ without a network service.
|
|
|
39
31
|
|
|
40
32
|
## Guides
|
|
41
33
|
|
|
34
|
+
- [Business operations](OPERATIONS.md): exact per-operation approval, durable retries and effect contracts.
|
|
35
|
+
|
|
42
36
|
- [Authentication](AUTH.md): sessions, Direct/Device/Mediated, consent, renewal and revocation.
|
|
43
37
|
- [Storage](STORAGE.md): targeted adapters, secret references, recovery and explicit resets.
|
|
44
38
|
- [Delivery](DELIVERY.md): messages, streaming, cancellation, retries and retention.
|
|
@@ -50,5 +44,5 @@ without a network service.
|
|
|
50
44
|
Keep Account Tokens, DPoP keys and PKCE in a secure SecretStore; never expose them
|
|
51
45
|
to model tools. An uncertain remote result must not be silently replayed. SQLite
|
|
52
46
|
is optional/experimental and is not vault encryption. Production issuers, vaults,
|
|
53
|
-
browsers and hosting require operator validation;
|
|
54
|
-
|
|
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,6 +90,19 @@ recovery of a token that the issuer rotated but never delivered.
|
|
|
88
90
|
|
|
89
91
|
## Development reset
|
|
90
92
|
|
|
93
|
+
Normalized auth state versions 1 and 2 upgrade to version 3 atomically on first access
|
|
94
|
+
in both SQLite and PostgreSQL, under the namespace's writer lock. Stop old SDK
|
|
95
|
+
processes before opening the candidate adapters; mixed-version writers are not
|
|
96
|
+
supported. This is a record upgrade, with no SQL schema change. Owner, epoch,
|
|
97
|
+
stable User IDs, grants, scopes, cleanup and internal connection/operation IDs stay.
|
|
98
|
+
Legacy connections discard their volatile token slots and require a fresh key on
|
|
99
|
+
next renewal. Old shared-key references remain retained, but are never used for
|
|
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
|
|
102
|
+
ones still pass through `recover()`, preserving rotated tokens and uncertainty.
|
|
103
|
+
Custom targeted adapters must provide the same complete-namespace upgrade under
|
|
104
|
+
their writer lock; the client's fallback also upgrades whole-state snapshots.
|
|
105
|
+
|
|
91
106
|
There is no automatic migration from old whole-state development stores. Stop all
|
|
92
107
|
users of a disposable store, resolve uncertain remote outcomes, and explicitly move
|
|
93
108
|
aside its database and WAL/SHM files and the matching host-managed secret namespace
|
|
@@ -171,3 +186,7 @@ transaction handle. It uses the supplied handle rather than its configured pool;
|
|
|
171
186
|
the host owns the entire commit/rollback. Savepoints protect individual provider
|
|
172
187
|
operations, while the host must abort the outer transaction on failure. Never
|
|
173
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
|
@@ -6,7 +6,7 @@ export function runClientConversationAdapterContract(fixture) {
|
|
|
6
6
|
const f = await fixture();
|
|
7
7
|
try {
|
|
8
8
|
const key = { connectionId: 'contract', issuer: 'https://issuer.example/one', endpoint: 'https://company.example/conversations' };
|
|
9
|
-
const row = { ...key, id: 'message', fingerprint: 'original', phase: 'pending', target: null, request: { message: { id: 'message',
|
|
9
|
+
const row = { ...key, id: 'message', fingerprint: 'original', phase: 'pending', target: null, request: { message: { id: 'message', author: { type: 'agent' }, text: 'hello' } } };
|
|
10
10
|
const saved = await Promise.all([f.store.prepareOutbound(key, row), f.second.prepareOutbound(key, { ...row, fingerprint: 'changed' })]);
|
|
11
11
|
assert.equal(saved[0].fingerprint, saved[1].fingerprint);
|
|
12
12
|
saved[0].request.message.text = 'mutated';
|
|
@@ -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,5 @@
|
|
|
1
|
+
import type { BusinessClientContext } from '../operations.js';
|
|
2
|
+
import type { Alternative, ExcludedTask } from '../network/tasks.js';
|
|
1
3
|
import type { AuthSelection, AuthStore } from './store.js';
|
|
2
4
|
import type { OperationOwner } from './ownership.js';
|
|
3
5
|
import type { JsonObject } from '../protocol/wire.js';
|
|
@@ -18,20 +20,20 @@ export interface IdentitySigner {
|
|
|
18
20
|
export type AssertionRequest = {
|
|
19
21
|
operationId: string;
|
|
20
22
|
issuer: string;
|
|
21
|
-
} & ({
|
|
22
23
|
purpose: 'client-authentication';
|
|
23
|
-
}
|
|
24
|
-
purpose: 'session';
|
|
25
|
-
} | {
|
|
26
|
-
purpose: 'browser';
|
|
27
|
-
sessionId: string;
|
|
28
|
-
returnTo: string;
|
|
29
|
-
});
|
|
24
|
+
};
|
|
30
25
|
export interface AssertionProvider {
|
|
31
26
|
assert(request: AssertionRequest): Promise<{
|
|
32
27
|
assertion: string;
|
|
33
28
|
expiresAt: number;
|
|
34
29
|
}>;
|
|
30
|
+
delegate?(request: {
|
|
31
|
+
operationId: string;
|
|
32
|
+
jwk: JsonObject;
|
|
33
|
+
}): Promise<{
|
|
34
|
+
delegation: string;
|
|
35
|
+
expiresAt: number;
|
|
36
|
+
}>;
|
|
35
37
|
}
|
|
36
38
|
export interface ProviderIdentity {
|
|
37
39
|
identity(issuer: string): Promise<{
|
|
@@ -41,6 +43,7 @@ export interface ProviderIdentity {
|
|
|
41
43
|
}
|
|
42
44
|
export type SignInMethod = 'direct' | 'device' | 'mediated';
|
|
43
45
|
export interface AuthorizationRequirement {
|
|
46
|
+
scopeDescriptions?: Record<string, string>;
|
|
44
47
|
requiredScopes: string[];
|
|
45
48
|
grantedScopes: string[];
|
|
46
49
|
missingScopes: string[];
|
|
@@ -149,9 +152,11 @@ export interface ConversationTarget {
|
|
|
149
152
|
export interface ClientAuth {
|
|
150
153
|
start(domain: string, input?: {
|
|
151
154
|
anonymous?: boolean;
|
|
155
|
+
accountFrom?: string;
|
|
152
156
|
}): Promise<AuthOutcome>;
|
|
153
157
|
get(connectionId: string): Promise<Connection | null>;
|
|
154
158
|
list(): Promise<Connection[]>;
|
|
159
|
+
businessContext(connectionId: string): Promise<BusinessClientContext>;
|
|
155
160
|
target(connectionId: string): Promise<ConversationTarget>;
|
|
156
161
|
authorization(connectionId: string, requiredScopes: readonly string[]): Promise<AuthorizationRequirement>;
|
|
157
162
|
beginSignIn(connectionId: string, input: SignInInput): Promise<SignInStart>;
|
|
@@ -169,11 +174,12 @@ export interface ClientAuth {
|
|
|
169
174
|
signOut(connectionId: string): Promise<SignOutOutcome>;
|
|
170
175
|
retryCleanup(connectionId: string): Promise<SignOutOutcome>;
|
|
171
176
|
recover(): Promise<AuthOutcome[]>;
|
|
172
|
-
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>;
|
|
173
178
|
apiTarget(connectionId: string, apiUrl: string, destination?: string): Promise<ApiTarget>;
|
|
174
|
-
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>;
|
|
175
180
|
joinBrowser(connectionId: string, returnTo: string, controller: AgentBrowserController): Promise<void>;
|
|
176
|
-
feedback(connectionId: string, report: FeedbackReport): Promise<void>;
|
|
181
|
+
feedback(connectionId: string, report: FeedbackReport): Promise<Alternative | void>;
|
|
182
|
+
excludedTasks(connectionId: string): Promise<ExcludedTask[]>;
|
|
177
183
|
}
|
|
178
184
|
export interface CredentialRecord {
|
|
179
185
|
secretRef: string;
|
|
@@ -197,6 +203,13 @@ export interface ConnectionRecord {
|
|
|
197
203
|
slots: Record<string, CredentialRecord>;
|
|
198
204
|
anonymous?: boolean;
|
|
199
205
|
notBefore?: string;
|
|
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;
|
|
@@ -239,6 +252,7 @@ export interface OperationRecord {
|
|
|
239
252
|
method?: SignInMethod;
|
|
240
253
|
requiredExistingScopes?: string[];
|
|
241
254
|
signInSessionId?: string;
|
|
255
|
+
signInSessionKey?: string;
|
|
242
256
|
device?: {
|
|
243
257
|
codeRef: string;
|
|
244
258
|
intervalMs: number;
|
|
@@ -270,7 +284,7 @@ export interface CleanupRecord {
|
|
|
270
284
|
notBefore?: string;
|
|
271
285
|
}
|
|
272
286
|
export interface ClientAuthState {
|
|
273
|
-
version: 1;
|
|
287
|
+
version: 1 | 2 | 3;
|
|
274
288
|
epoch: string;
|
|
275
289
|
owner: {
|
|
276
290
|
namespace: string;
|
|
@@ -279,7 +293,7 @@ export interface ClientAuthState {
|
|
|
279
293
|
} | null;
|
|
280
294
|
identities: Record<string, {
|
|
281
295
|
userId: string;
|
|
282
|
-
dpopKeyRef
|
|
296
|
+
dpopKeyRef?: string;
|
|
283
297
|
}>;
|
|
284
298
|
connections: Record<string, ConnectionRecord>;
|
|
285
299
|
grants: Record<string, GrantRecord>;
|
|
@@ -294,4 +308,6 @@ export declare class AuthFailure extends Error {
|
|
|
294
308
|
constructor(code: string, connectionId?: string | undefined, retryAfterMs?: number | undefined, retryAt?: string | undefined);
|
|
295
309
|
}
|
|
296
310
|
export declare function initialClientAuthState(): ClientAuthState;
|
|
311
|
+
/** Upgrade a complete namespace under its writer lock; dispatched/staged work still goes through recover(). */
|
|
312
|
+
export declare function upgradeClientAuthState(state: ClientAuthState): void;
|
|
297
313
|
export declare function createClientAuth(options: ClientAuthOptions): ClientAuth;
|