@poppykit/client 0.3.0 → 0.4.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.
- package/API.md +9 -0
- package/AUTH.md +16 -0
- package/CHANGELOG.md +22 -0
- package/CONTRIBUTING.md +10 -2
- package/PROVIDER.md +39 -1
- package/README.md +6 -4
- package/RELEASING.md +2 -2
- package/STORAGE.md +21 -4
- package/dist/auth/client-auth.js +45 -25
- package/dist/auth/sqlite-store.js +6 -0
- package/dist/auth/store.d.ts +3 -1
- package/dist/postgres-runtime.d.ts +2 -0
- package/dist/postgres-runtime.js +27 -0
- package/dist/postgres.js +2 -0
- package/dist/provider-postgres.d.ts +4 -0
- package/dist/provider-postgres.js +2 -0
- package/dist/provider.d.ts +2 -2
- package/dist/provider.js +20 -9
- package/package.json +2 -2
package/API.md
CHANGED
|
@@ -27,3 +27,12 @@ or general API executors.
|
|
|
27
27
|
The optional Postgres entry point ships its adapters, `createPostgresPool`,
|
|
28
28
|
`postgresMigration`, `migratePostgres` and schema-version export; see [storage](STORAGE.md).
|
|
29
29
|
TypeScript Postgres consumers also install `@types/pg@8.23.1` as a development dependency.
|
|
30
|
+
|
|
31
|
+
Custom `AuthStore` implementations must honor `AuthSelection.history` as described
|
|
32
|
+
in [STORAGE.md](STORAGE.md). Provider callbacks now acknowledge identical consumed
|
|
33
|
+
results without making `takeResult` reusable; see [PROVIDER.md](PROVIDER.md).
|
|
34
|
+
|
|
35
|
+
`PostgresProviderStore.withTransaction(connection, action)` and its structural
|
|
36
|
+
`PostgresConnection` type are exported only by `@poppykit/client/provider/postgres`.
|
|
37
|
+
They explicitly enlist provider SQL in a host-owned guarded transaction; the host
|
|
38
|
+
retains commit/rollback, deadline and privilege responsibility.
|
package/AUTH.md
CHANGED
|
@@ -16,6 +16,11 @@ implements this adapter. Values are cleared after each submission and never
|
|
|
16
16
|
persisted by the SDK or exposed in public results. Input adapters must not log
|
|
17
17
|
values, keep copies, or return them through model tools.
|
|
18
18
|
|
|
19
|
+
Direct callbacks accept OAuth error details and ignore unknown response fields.
|
|
20
|
+
The registered origin/path/query, browser binding, state and exact issuer still
|
|
21
|
+
have to match; duplicate recognized fields and a simultaneous code/error fail.
|
|
22
|
+
Error descriptions and error URLs are never included in public auth outcomes.
|
|
23
|
+
|
|
19
24
|
First-time partial grants complete with `grantedScopes` and `missingScopes`.
|
|
20
25
|
Replacement preserves every old grant scope by default. An explicit
|
|
21
26
|
`requiredExistingScopes` subset permits dropping other old scopes. A rejected
|
|
@@ -43,6 +48,17 @@ separate local `IdentitySigner` is required and its key must also be published i
|
|
|
43
48
|
the client's JWKS. A provider-only configuration reports `anonymous_unavailable`;
|
|
44
49
|
it never substitutes the provider's stable identity for an anonymous one.
|
|
45
50
|
|
|
51
|
+
Provider `expiresAt` values are Unix seconds. Session/client assertions and browser
|
|
52
|
+
joins accept a local freshness horizon of 60 seconds plus 30 seconds of clock skew;
|
|
53
|
+
an assertion at or before the local current time is rejected. This freshness check
|
|
54
|
+
does not verify the signed assertion's lifetime: the issuer verifies the signature,
|
|
55
|
+
claims, replay and protocol maximum of five minutes between `iat` and `exp`.
|
|
56
|
+
`provider_clock_skew` and `provider_assertion_expired` suggest checking automatic
|
|
57
|
+
date/time and retrying. `provider_invalid_response`, `provider_unauthorized`,
|
|
58
|
+
`provider_rate_limited` and `provider_network_error` distinguish malformed data,
|
|
59
|
+
enrollment rejection, throttling and connectivity. Unknown failures remain
|
|
60
|
+
`provider_unavailable`. Errors never include assertion or provider response text.
|
|
61
|
+
|
|
46
62
|
Account Tokens, device codes, PKCE and DPoP keys belong in the configured
|
|
47
63
|
SecretStore; Session Tokens stay in memory. SQLite and secret storage are separate
|
|
48
64
|
commit boundaries. A host must sweep orphan staged secrets only after checking
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
Support Node 24.21.0 and later within Node 24 under the owner-accepted runtime policy.
|
|
6
|
+
Build, verification and publication tooling read .node-version.
|
|
7
|
+
|
|
8
|
+
## 0.4.0
|
|
9
|
+
|
|
10
|
+
Require Node 24 >=24.22.1 within 24.x. Accept conforming OAuth callback fields,
|
|
11
|
+
decode chunked UTF-8 strictly and bound durable terminal-operation history.
|
|
12
|
+
Custom auth stores must implement the documented `history` selection. The
|
|
13
|
+
explicit provider Postgres adapter can enlist in a guarded host transaction for
|
|
14
|
+
atomic consent delivery; protocol-only imports still load no database drivers.
|
|
15
|
+
Required release verification now includes isolated PostgreSQL contracts.
|
|
16
|
+
|
|
17
|
+
## 0.3.1 (release candidate)
|
|
18
|
+
|
|
19
|
+
Allow 30 seconds of provider clock skew in the local assertion freshness check,
|
|
20
|
+
including browser joins. Already-expired assertions remain rejected; the issuer
|
|
21
|
+
still verifies signatures, signed lifetime and replay. Safe errors now distinguish
|
|
22
|
+
clock/expiry, malformed responses, provider authorization, rate limits and network
|
|
23
|
+
failure. No wire protocol, storage format or dependency change.
|
|
24
|
+
|
|
3
25
|
## 0.3.0 (release candidate)
|
|
4
26
|
|
|
5
27
|
Add the optional challenge-bound browser enrollment client under `/provider`.
|
package/CONTRIBUTING.md
CHANGED
|
@@ -5,8 +5,8 @@ and [guides](https://personalagentprotocol.org/docs/0.2/guides). Tests/snapshots
|
|
|
5
5
|
supporting evidence. Demo/business workflows and individual extensions are excluded.
|
|
6
6
|
Code is [MIT](LICENSE); confirm rights for third-party contributions.
|
|
7
7
|
|
|
8
|
-
Use Node 24.
|
|
9
|
-
Install the packageManager-pinned pnpm and run pnpm install --frozen-lockfile.
|
|
8
|
+
Use Node 24.21.0, TypeScript 5.9.3 and @types/node 24.10.0.
|
|
9
|
+
Install the packageManager-pinned pnpm and run pnpm install --frozen-lockfile --ignore-scripts.
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
12
|
npm run typecheck
|
|
@@ -36,3 +36,11 @@ Preserve credential/destination validation, private errors and persisted data.
|
|
|
36
36
|
Report checks actually run and limits. Use Issues for non-sensitive defects and
|
|
37
37
|
[SECURITY.md](SECURITY.md) for vulnerabilities. Follow [RELEASING.md](RELEASING.md)
|
|
38
38
|
before tagging/publishing; obtain explicit owner authorization.
|
|
39
|
+
|
|
40
|
+
Required CI also runs scripts/postgres-ci.mjs in a dedicated disposable PostgreSQL17
|
|
41
|
+
cluster with POPPY_DISPOSABLE_POSTGRES=1, D1_LOCAL=1 and explicit loopback PGHOST,
|
|
42
|
+
PGPORT, PGUSER, PGPASSWORD, PGDATABASE. Build production/tests first; use a fresh
|
|
43
|
+
database without SDK schemas. Never point it at shared/cloud data. It migrates
|
|
44
|
+
only this package's schemas, runs all named durable suites, rejects skips/missing
|
|
45
|
+
discovery and leaves cluster destruction to its owner. Default source-suite skips
|
|
46
|
+
do not replace this gate. See the workflow for the pinned image and setup.
|
package/PROVIDER.md
CHANGED
|
@@ -14,7 +14,7 @@ Import `createProvider`, `createProviderClient`, and `createResultCipher` from
|
|
|
14
14
|
`@poppykit/client/provider`; durable `SqliteProviderStore` is separately exported
|
|
15
15
|
from `@poppykit/client/provider/sqlite`. Neither is loaded by the core import.
|
|
16
16
|
The wire protocol remains PoppyKit provider **v1**; the 0.2 TypeScript API uses async storage, not a Personal Agent Protocol extension.
|
|
17
|
-
Node 24.
|
|
17
|
+
Node 24.x (at least 24.21.0) is required for the SQLite reference adapter.
|
|
18
18
|
|
|
19
19
|
An operator supplies HTTPS hosting, a provider client metadata URL, JWKS URL,
|
|
20
20
|
HTTPS callback URL on that same domain, an ES256 signer/KMS, a verified company
|
|
@@ -22,6 +22,44 @@ metadata resolver, authenticated enrollment and encrypted callback-result storag
|
|
|
22
22
|
The provider has no company account-token vault, token-exchange proxy, login UI,
|
|
23
23
|
deployment dependency or cloud dependency.
|
|
24
24
|
|
|
25
|
+
HTTP JSON is limited to 16 KiB of UTF-8 bytes. Chunk boundaries preserve Unicode;
|
|
26
|
+
invalid/truncated UTF-8 fails before enrollment, signing or operation creation.
|
|
27
|
+
Callbacks accept optional OAuth error details and unknown response fields while
|
|
28
|
+
checking state/issuer, destination and duplicate recognized fields. An identical
|
|
29
|
+
callback after consumption acknowledges `consumed` without recreating ciphertext;
|
|
30
|
+
a changed callback conflicts. `takeResult` remains one-time, including after restart.
|
|
31
|
+
|
|
32
|
+
`ProviderError` codes `unauthorized`, `expired` and `canceled` are definite
|
|
33
|
+
rejections. `consumed` means retrieval already happened and does not prove the
|
|
34
|
+
caller staged its result. Network failure, `unavailable` and `invalid_response`
|
|
35
|
+
are ambiguous; preserve uncertainty rather than repeating one-time retrieval.
|
|
36
|
+
`rate_limited` carries a retry deadline. Never expose raw errors, credentials,
|
|
37
|
+
assertions or callback URLs through model tools.
|
|
38
|
+
|
|
39
|
+
Postgres hosts can explicitly enlist the store in their existing transaction:
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
await companyDatabase.withTransaction(async connection => {
|
|
43
|
+
await providerStore.withTransaction(connection, async () => {
|
|
44
|
+
const callback = await issuer.completeAuthorization(id, browserBinding, decision);
|
|
45
|
+
await provider.callback(callback.href);
|
|
46
|
+
await saveHostReceipt(connection); // host-owned bounded SQL
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The handle must come from `PostgresCompanyDatabase.withTransaction` (or an
|
|
52
|
+
equivalent host guard that rejects expired/escaped handles), on the same database
|
|
53
|
+
with the required provider schema privileges. Normal operations still use the
|
|
54
|
+
configured provider pool; enlisted operations never fall back to it. The host
|
|
55
|
+
owns commit/rollback and must propagate failures to abort the whole transaction.
|
|
56
|
+
Only bounded SQL and synchronous cryptography belong in the scope; no HTTP,
|
|
57
|
+
model, signing service or other remote work. Persist the authenticated human
|
|
58
|
+
decision first, then issue the code, provider-ready callback and host receipt in
|
|
59
|
+
one atomic transaction. A rolled-back attempt can retry that decision; an
|
|
60
|
+
uncertain one-time code/token redemption must never be retried. Escaped participant
|
|
61
|
+
work fails closed. Code/consent lifetimes are unchanged.
|
|
62
|
+
|
|
25
63
|
```js
|
|
26
64
|
import { createProvider, createResultCipher } from '@poppykit/client/provider';
|
|
27
65
|
import { SqliteProviderStore } from '@poppykit/client/provider/sqlite';
|
package/README.md
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
# PoppyKit client
|
|
2
2
|
|
|
3
|
+
The Node 24.21.0 minimum is an owner-accepted compatibility baseline. Use a current patched Node 24 runtime where available; this minimum does not establish that older runtimes contain later security fixes. Build and release CI read `.node-version`.
|
|
4
|
+
|
|
3
5
|
TypeScript SDK for [Personal Agent Protocol draft 0.2](https://personalagentprotocol.org/docs/0.2/spec):
|
|
4
6
|
discovery, authentication, conversations and API/browser/feedback hooks. Your
|
|
5
7
|
application supplies the agent and trusted infrastructure. [MIT](LICENSE).
|
|
6
8
|
|
|
7
9
|
## Install
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
This source contains unreleased repairs; final version selection and publication are pending.
|
|
10
12
|
|
|
11
|
-
Requires Node **24.
|
|
12
|
-
Windows;
|
|
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.
|
|
13
15
|
|
|
14
16
|
```sh
|
|
15
|
-
npm install @poppykit/client
|
|
17
|
+
npm install @poppykit/client
|
|
16
18
|
```
|
|
17
19
|
|
|
18
20
|
## Discover a company
|
package/RELEASING.md
CHANGED
|
@@ -6,7 +6,7 @@ changing runtime APIs. Each new version needs its own source/artifact evidence.
|
|
|
6
6
|
|
|
7
7
|
## Before tagging
|
|
8
8
|
|
|
9
|
-
- Confirm npm ownership, unused versions, supported Node 24.
|
|
9
|
+
- Confirm npm ownership, unused versions, supported Node 24.x (at least 24.21.0) and npm>=11.5.1.
|
|
10
10
|
Keep the pinned compiler/dependencies; do not upgrade tooling during a release.
|
|
11
11
|
- Update version, changelog and shipped docs. CLI's client dependency and both
|
|
12
12
|
candidate JSON files must match exact final SDK versions/full source SHAs.
|
|
@@ -47,7 +47,7 @@ guard requires its tested client tarball integrity to equal the published one.
|
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
4. Wait for candidate, fast, contracts, integration, packaged-linux/windows/macos,
|
|
50
|
-
validation and gate to succeed. Missing, failed, canceled or skipped prerequisites
|
|
50
|
+
validation, postgres and gate to succeed. The postgres job uses its own disposable PostgreSQL17 cluster and the same-run checked candidate/harness; zero discovery or any skipped database case fails. Missing, failed, canceled or skipped prerequisites
|
|
51
51
|
fail closed. Then follow the Actions environment-approval link. Do not bypass it.
|
|
52
52
|
5. The publisher rechecks source/tag/ref/version, trusted manifest digest, unchanged
|
|
53
53
|
tarball metadata/bytes, owner enablement, registry absence and dependency integrity.
|
package/STORAGE.md
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
# Persistence and recovery
|
|
2
2
|
|
|
3
|
-
Use targeted storage contracts. Node 24.
|
|
4
|
-
or later is required. The optional `@poppykit/client/sqlite` entry point exports
|
|
3
|
+
Use targeted storage contracts. Node 24.x, at least 24.21.0, is required. The optional `@poppykit/client/sqlite` entry point exports
|
|
5
4
|
`SqliteAuthStore` and `SqliteConversationStore`; importing the client core does not
|
|
6
|
-
load SQLite. `node:sqlite` is experimental in the tested Node 24.
|
|
5
|
+
load SQLite. `node:sqlite` is experimental in the tested Node 24.21.0 runtime.
|
|
7
6
|
Consumers can implement `AuthStore` and `ConversationStore` using their own database.
|
|
8
7
|
|
|
9
8
|
```ts
|
|
@@ -30,6 +29,17 @@ operation and its owner inside its transaction. `cleanup(before, limit)` removes
|
|
|
30
29
|
only terminal, non-uncertain auth history; the limit is at most 1000. It does not
|
|
31
30
|
erase grants, pending exchanges, uncertain results, or secrets.
|
|
32
31
|
|
|
32
|
+
Starting a connection or sign-in also selects bounded terminal history with
|
|
33
|
+
`history: true`: at most the newest 201 eligible operation records across this
|
|
34
|
+
auth namespace, without loading their connections. Before creating new work it
|
|
35
|
+
keeps 100 terminal start/sign-in results (creation time, then ID order); the just
|
|
36
|
+
completed result can bring that to 101. Repeated starts drain older excess in
|
|
37
|
+
bounded batches. Pending, dispatched, staged and uncertain operations remain;
|
|
38
|
+
grant references, retirement cleanup and conversation receipts are unaffected.
|
|
39
|
+
Custom AuthStore adapters must honor this history selection. Old pruned callback
|
|
40
|
+
state becomes unknown; uncertain callbacks retain their recorded result and are
|
|
41
|
+
never exchanged again. No vault or connection reset is involved.
|
|
42
|
+
|
|
33
43
|
Transactions must serialize conflicting domain updates across processes, roll back
|
|
34
44
|
on exceptions, reject asynchronous mutation callbacks and non-JSON persisted values,
|
|
35
45
|
enforce unique keys, and return detached values. SQLite uses WAL, FULL synchronous
|
|
@@ -100,7 +110,7 @@ never deletes company history, revokes access or cancels accepted business work.
|
|
|
100
110
|
The optional `@poppykit/client/postgres` exports `PostgresAuthStore` and
|
|
101
111
|
`PostgresConversationStore`; `/provider/postgres` exports `PostgresProviderStore`.
|
|
102
112
|
Install the optional peer `pg@8.23.1` only when choosing these subpaths.
|
|
103
|
-
Protocol-only imports load neither pg nor SQLite. PostgreSQL 17 and Node 24.
|
|
113
|
+
Protocol-only imports load neither pg nor SQLite. PostgreSQL 17 and Node 24.x (at least 24.21.0)
|
|
104
114
|
are supported; plain PostgreSQL and Supabase transaction pooling use the same adapter.
|
|
105
115
|
There is no Supabase Auth or application dependency.
|
|
106
116
|
|
|
@@ -154,3 +164,10 @@ Custom backends can run `runClientConversationAdapterContract` and
|
|
|
154
164
|
handles to a fresh shared namespace. Real process death and engine atomicity still
|
|
155
165
|
need backend-specific checks. Database credentials and encryption keys stay outside
|
|
156
166
|
records, prompts and logs; provider callback URLs remain encrypted by ResultCipher.
|
|
167
|
+
|
|
168
|
+
For atomic issuer/provider/host delivery, explicitly enlist `PostgresProviderStore`
|
|
169
|
+
using its `withTransaction(connection, action)` method and the host's guarded
|
|
170
|
+
transaction handle. It uses the supplied handle rather than its configured pool;
|
|
171
|
+
the host owns the entire commit/rollback. Savepoints protect individual provider
|
|
172
|
+
operations, while the host must abort the outer transaction on failure. Never
|
|
173
|
+
span model/network I/O. See the [provider example](PROVIDER.md).
|
package/dist/auth/client-auth.js
CHANGED
|
@@ -26,6 +26,12 @@ const SKEW_MS = 30_000;
|
|
|
26
26
|
const AMBIGUOUS = new Set(['transport_error', 'invalid_response', 'token_error']);
|
|
27
27
|
const CLIENT_ASSERTION = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
|
|
28
28
|
const ASSERTION_SECONDS = 60;
|
|
29
|
+
function providerFailure(error, connectionId) {
|
|
30
|
+
const code = error?.code;
|
|
31
|
+
return new AuthFailure(code === 'unauthorized' ? 'provider_unauthorized' : code === 'rate_limited' ? 'provider_rate_limited'
|
|
32
|
+
: ['timeout', 'network_error', 'blocked_address', 'overloaded', 'closed'].includes(String(code)) ? 'provider_network_error'
|
|
33
|
+
: ['invalid_response', 'binding_conflict', 'expired', 'consumed', 'canceled'].includes(String(code)) ? 'provider_invalid_response' : 'provider_unavailable', connectionId);
|
|
34
|
+
}
|
|
29
35
|
// OAuth codes the token endpoint may return (spec 4.2, RFC 6749/8707); anything else is reported as token_error.
|
|
30
36
|
const SAFE_ERRORS = new Set(['invalid_request', 'invalid_client', 'invalid_grant', 'unauthorized_client', 'unsupported_grant_type', 'invalid_scope', 'invalid_target', 'invalid_session', 'account_mismatch', 'invalid_dpop_proof', 'use_dpop_nonce', 'extension_required', 'rate_limited']);
|
|
31
37
|
const b64 = (data) => Buffer.from(data).toString('base64url');
|
|
@@ -55,8 +61,11 @@ function allowedHost(value, hosts) {
|
|
|
55
61
|
const hostname = new URL(value).hostname.toLowerCase();
|
|
56
62
|
return hosts.some(host => hostname === host.toLowerCase() || hostname.endsWith(`.${host.toLowerCase()}`));
|
|
57
63
|
}
|
|
58
|
-
//
|
|
59
|
-
|
|
64
|
+
// Ignore unknown OAuth response fields, but preserve every registered redirect component.
|
|
65
|
+
function redirectMatches(url, registered) {
|
|
66
|
+
return url.origin === registered.origin && url.username === registered.username && url.password === registered.password && url.pathname === registered.pathname
|
|
67
|
+
&& [...registered.searchParams.keys()].every(key => JSON.stringify(url.searchParams.getAll(key)) === JSON.stringify(registered.searchParams.getAll(key)));
|
|
68
|
+
}
|
|
60
69
|
function parseCallback(callbackUrl) {
|
|
61
70
|
if (typeof callbackUrl !== 'string' || !URL.canParse(callbackUrl))
|
|
62
71
|
throw new AuthFailure('invalid_callback');
|
|
@@ -226,15 +235,25 @@ export function createClientAuth(options) {
|
|
|
226
235
|
async function assertion(purpose, issuer, key, sub = clientId, operationId = randomId('op_')) {
|
|
227
236
|
if (!options.assertions)
|
|
228
237
|
return signIdentity({ typ: purpose === 'session' ? 'JWT' : 'client-authentication+jwt' }, assertionClaims(sub, issuer), key.thumbprint);
|
|
238
|
+
let result;
|
|
229
239
|
try {
|
|
230
|
-
|
|
231
|
-
if (typeof result.assertion !== 'string' || !result.assertion || !Number.isFinite(result.expiresAt) || result.expiresAt * 1000 <= now() || result.expiresAt * 1000 > now() + ASSERTION_SECONDS * 1000)
|
|
232
|
-
throw 0;
|
|
233
|
-
return result.assertion;
|
|
240
|
+
result = await options.assertions.assert({ operationId, issuer, purpose });
|
|
234
241
|
}
|
|
235
|
-
catch {
|
|
236
|
-
throw
|
|
242
|
+
catch (error) {
|
|
243
|
+
throw providerFailure(error);
|
|
237
244
|
}
|
|
245
|
+
return providerAssertion(result);
|
|
246
|
+
}
|
|
247
|
+
function providerAssertion(result, connectionId) {
|
|
248
|
+
if (!result || typeof result.assertion !== 'string' || !result.assertion || !Number.isFinite(result.expiresAt))
|
|
249
|
+
throw new AuthFailure('provider_invalid_response', connectionId);
|
|
250
|
+
const at = now(), expires = result.expiresAt * 1000;
|
|
251
|
+
if (expires <= at)
|
|
252
|
+
throw new AuthFailure('provider_assertion_expired', connectionId);
|
|
253
|
+
// Local freshness tolerance; the issuer still verifies signed claims, lifetime and replay.
|
|
254
|
+
if (expires > at + ASSERTION_SECONDS * 1000 + SKEW_MS)
|
|
255
|
+
throw new AuthFailure('provider_clock_skew', connectionId);
|
|
256
|
+
return result.assertion;
|
|
238
257
|
}
|
|
239
258
|
function responseFailure(response, code) {
|
|
240
259
|
const value = response.headers.get('retry-after');
|
|
@@ -340,16 +359,19 @@ export function createClientAuth(options) {
|
|
|
340
359
|
const dpopKeyRef = await secrets.stage(JSON.stringify({ kty, crv, x, y, d }));
|
|
341
360
|
let userId = randomId('usr_');
|
|
342
361
|
if (options.providerIdentity && !anonymous) {
|
|
362
|
+
let bound;
|
|
343
363
|
try {
|
|
344
|
-
|
|
345
|
-
if (bound.clientId !== clientId || typeof bound.userId !== 'string' || !bound.userId)
|
|
346
|
-
throw 0;
|
|
347
|
-
userId = bound.userId;
|
|
364
|
+
bound = await options.providerIdentity.identity(issuer);
|
|
348
365
|
}
|
|
349
|
-
catch {
|
|
366
|
+
catch (error) {
|
|
367
|
+
await secrets.remove(dpopKeyRef);
|
|
368
|
+
throw providerFailure(error);
|
|
369
|
+
}
|
|
370
|
+
if (!bound || bound.clientId !== clientId || typeof bound.userId !== 'string' || !bound.userId) {
|
|
350
371
|
await secrets.remove(dpopKeyRef);
|
|
351
|
-
throw new AuthFailure('
|
|
372
|
+
throw new AuthFailure('provider_invalid_response');
|
|
352
373
|
}
|
|
374
|
+
userId = bound.userId;
|
|
353
375
|
}
|
|
354
376
|
if (anonymous)
|
|
355
377
|
return { userId, dpopKeyRef };
|
|
@@ -402,8 +424,7 @@ export function createClientAuth(options) {
|
|
|
402
424
|
}
|
|
403
425
|
// Terminal start/sign-in results only answer late duplicate callbacks, so keep the newest few; uncertain ones decide the cleanup status.
|
|
404
426
|
function pruneHistory(state) {
|
|
405
|
-
|
|
406
|
-
const done = Object.values(state.operations).reverse().filter(op => op.kind !== 'renew' && op.phase === 'terminal' && op.status !== 'uncertain').sort((a, b) => b.createdAt - a.createdAt);
|
|
427
|
+
const done = Object.values(state.operations).filter(op => op.kind !== 'renew' && op.phase === 'terminal' && op.status !== 'uncertain').sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? 1 : a.id > b.id ? -1 : 0));
|
|
407
428
|
for (const op of done.slice(KEEP_OPERATIONS))
|
|
408
429
|
delete state.operations[op.id];
|
|
409
430
|
}
|
|
@@ -1206,7 +1227,7 @@ export function createClientAuth(options) {
|
|
|
1206
1227
|
const token = await signedOutToken(metadata, userId, dpopKeyRef, null, null, anonymous);
|
|
1207
1228
|
const secretRef = await stageSessionToken(token.access_token);
|
|
1208
1229
|
const id = randomId('con_'), operationId = randomId('op_');
|
|
1209
|
-
return commit({ issuer }, [secretRef], state => {
|
|
1230
|
+
return commit({ issuer, history: true }, [secretRef], state => {
|
|
1210
1231
|
// A reset or competing identity since we signed means this session belongs to no current identity.
|
|
1211
1232
|
if (!anonymous && own(state.identities, issuer)?.dpopKeyRef !== dpopKeyRef)
|
|
1212
1233
|
throw new AuthFailure('state_changed');
|
|
@@ -1304,7 +1325,7 @@ export function createClientAuth(options) {
|
|
|
1304
1325
|
const verifier = b64(crypto.getRandomValues(new Uint8Array(32)));
|
|
1305
1326
|
const verifierRef = method === 'direct' ? await secrets.stage(verifier) : null;
|
|
1306
1327
|
const createdAt = now(), operationId = randomId('op_');
|
|
1307
|
-
const outcome = await commit({ connectionId }, [verifierRef], draft => {
|
|
1328
|
+
const outcome = await commit({ connectionId, history: true }, [verifierRef], draft => {
|
|
1308
1329
|
const current = own(draft.connections, connectionId);
|
|
1309
1330
|
if (!current || current.generation !== connection.generation || current.status !== connection.status || current.sessionId !== connection.sessionId)
|
|
1310
1331
|
throw new AuthFailure('state_changed', connectionId);
|
|
@@ -1342,7 +1363,7 @@ export function createClientAuth(options) {
|
|
|
1342
1363
|
const found = Object.values(snapshot.operations).find(operation => operation.kind === 'sign_in' && operation.epoch === snapshot.epoch && operation.direct?.stateHash === stateHash);
|
|
1343
1364
|
const direct = found?.direct;
|
|
1344
1365
|
// A wrong browser, redirect or issuer (mix-up, spec 4.5) leaves the operation untouched so the genuine callback still completes.
|
|
1345
|
-
if (!found || !direct || direct.browserHash !== browserHash ||
|
|
1366
|
+
if (!found || !direct || direct.browserHash !== browserHash || !redirectMatches(base, new URL(direct.redirectUri)) || params.iss !== direct.issuer)
|
|
1346
1367
|
throw new AuthFailure('invalid_callback');
|
|
1347
1368
|
checkCooldown(await record(found.connectionId));
|
|
1348
1369
|
const operationId = found.id;
|
|
@@ -1540,15 +1561,14 @@ export function createClientAuth(options) {
|
|
|
1540
1561
|
const key = await loadDpopKey(connection.dpopKeyRef);
|
|
1541
1562
|
let value;
|
|
1542
1563
|
if (options.assertions && !connection.anonymous) {
|
|
1564
|
+
let result;
|
|
1543
1565
|
try {
|
|
1544
|
-
|
|
1545
|
-
if (!result.assertion || result.expiresAt * 1000 <= now() || result.expiresAt * 1000 > now() + 60000)
|
|
1546
|
-
throw 0;
|
|
1547
|
-
value = result.assertion;
|
|
1566
|
+
result = await options.assertions.assert({ purpose: 'browser', operationId: randomId('op_'), issuer: connection.issuer, sessionId: connection.sessionId, returnTo });
|
|
1548
1567
|
}
|
|
1549
|
-
catch {
|
|
1550
|
-
throw
|
|
1568
|
+
catch (error) {
|
|
1569
|
+
throw providerFailure(error, connectionId);
|
|
1551
1570
|
}
|
|
1571
|
+
value = providerAssertion(result, connectionId);
|
|
1552
1572
|
}
|
|
1553
1573
|
else
|
|
1554
1574
|
value = await signIdentity({ typ: 'poppy-browser+jwt' }, { ...assertionClaims(connection.userId, endpoint), session_id: connection.sessionId, return_to: returnTo }, key.thumbprint);
|
|
@@ -51,8 +51,13 @@ export class SqliteAuthStore {
|
|
|
51
51
|
for (const row of this.db.prepare(`SELECT id,body FROM auth_${table} ${clause}`).all(...params))
|
|
52
52
|
state[table][row.id] = JSON.parse(row.body);
|
|
53
53
|
};
|
|
54
|
+
const history = () => {
|
|
55
|
+
if (selection.history)
|
|
56
|
+
rows('operations', "WHERE json_extract(body,'$.phase')='terminal' AND json_extract(body,'$.status')!='uncertain' AND json_extract(body,'$.kind')!='renew' ORDER BY json_extract(body,'$.createdAt') DESC,id DESC LIMIT 201");
|
|
57
|
+
};
|
|
54
58
|
if ('issuer' in selection) {
|
|
55
59
|
get('identities', selection.issuer);
|
|
60
|
+
history();
|
|
56
61
|
return state;
|
|
57
62
|
}
|
|
58
63
|
if ('connectionId' in selection)
|
|
@@ -76,6 +81,7 @@ export class SqliteAuthStore {
|
|
|
76
81
|
for (const operation of Object.values(state.operations))
|
|
77
82
|
if (operation.grantId)
|
|
78
83
|
get('grants', operation.grantId);
|
|
84
|
+
history();
|
|
79
85
|
return state;
|
|
80
86
|
}
|
|
81
87
|
async read(selection) {
|
package/dist/auth/store.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ClientAuthState } from './client-auth.js';
|
|
2
2
|
/** A connection aggregate includes its grants, operations and cleanup; never other connections. */
|
|
3
|
-
export type AuthSelection = {
|
|
3
|
+
export type AuthSelection = ({
|
|
4
4
|
connectionId: string;
|
|
5
5
|
} | {
|
|
6
6
|
operationId: string;
|
|
@@ -10,6 +10,8 @@ export type AuthSelection = {
|
|
|
10
10
|
callbackHash: string;
|
|
11
11
|
} | {
|
|
12
12
|
list: true;
|
|
13
|
+
}) & {
|
|
14
|
+
history?: true;
|
|
13
15
|
};
|
|
14
16
|
export interface AuthStore {
|
|
15
17
|
read(selection: AuthSelection): Promise<ClientAuthState>;
|
|
@@ -23,6 +23,8 @@ export declare class PostgresRuntime {
|
|
|
23
23
|
};
|
|
24
24
|
constructor(pool: Pool, schema: PostgresSchema, ownsPool?: boolean);
|
|
25
25
|
close(): Promise<void>;
|
|
26
|
+
/** Enlist in a host-owned transaction; the host handle must enforce its own lifetime. */
|
|
27
|
+
withConnection<T>(connection: PostgresConnection, action: () => Promise<T>): Promise<T>;
|
|
26
28
|
run<T>(action: () => Promise<T>): Promise<T>;
|
|
27
29
|
connection(): PostgresConnection;
|
|
28
30
|
query<R extends QueryResultRow = QueryResultRow>(sql: string, values?: unknown[]): Promise<R[]>;
|
package/dist/postgres-runtime.js
CHANGED
|
@@ -77,6 +77,33 @@ export class PostgresRuntime {
|
|
|
77
77
|
}
|
|
78
78
|
async close() { this.closed = true; if (this.ownsPool)
|
|
79
79
|
await this.pool.end(); }
|
|
80
|
+
/** Enlist in a host-owned transaction; the host handle must enforce its own lifetime. */
|
|
81
|
+
async withConnection(connection, action) {
|
|
82
|
+
if (this.closed)
|
|
83
|
+
throw new Error('Postgres adapter is closed');
|
|
84
|
+
if (this.context.getStore())
|
|
85
|
+
throw new Error('cannot enlist an adapter with an active Postgres transaction');
|
|
86
|
+
const context = { client: connection, active: true, locks: new Set(), state: { serial: 0, active: true }, childActive: false };
|
|
87
|
+
const savepoint = `enlisted_${this.schema}`;
|
|
88
|
+
// Probe only: rolling back a host scope would invalidate the owner's cached locks.
|
|
89
|
+
await connection.query(`SAVEPOINT ${savepoint}`);
|
|
90
|
+
await connection.query(`RELEASE SAVEPOINT ${savepoint}`);
|
|
91
|
+
try {
|
|
92
|
+
const result = await this.context.run(context, async () => {
|
|
93
|
+
const versions = await this.query(`SELECT version FROM ${this.schema}.schema_version`);
|
|
94
|
+
if (versions.length !== 1 || versions[0].version !== postgresSchemaVersion)
|
|
95
|
+
throw new Error('unsupported Postgres schema version');
|
|
96
|
+
return action();
|
|
97
|
+
});
|
|
98
|
+
if (context.childActive)
|
|
99
|
+
throw new Error('unawaited nested Postgres transaction');
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
context.active = false;
|
|
104
|
+
context.state.active = false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
80
107
|
async run(action) {
|
|
81
108
|
if (this.closed)
|
|
82
109
|
throw new Error('Postgres adapter is closed');
|
package/dist/postgres.js
CHANGED
|
@@ -52,6 +52,8 @@ export class PostgresAuthStore {
|
|
|
52
52
|
if (operation.grantId)
|
|
53
53
|
await one('grants', operation.grantId);
|
|
54
54
|
}
|
|
55
|
+
if (selection.history)
|
|
56
|
+
await rows('operations', "AND body::jsonb->>'phase'='terminal' AND body::jsonb->>'status'!='uncertain' AND body::jsonb->>'kind'!='renew' ORDER BY (body::jsonb->>'createdAt')::double precision DESC,id COLLATE \"C\" DESC LIMIT 201");
|
|
55
57
|
return state;
|
|
56
58
|
}
|
|
57
59
|
read(selection) { return this.db.run(() => this.select(selection)); }
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Pool } from 'pg';
|
|
2
2
|
import type { ProviderSelection, ProviderStore, ProviderTransaction } from './provider.js';
|
|
3
|
+
import type { PostgresConnection } from './postgres-runtime.js';
|
|
3
4
|
export { createPostgresPool, migratePostgres, postgresMigration, postgresSchemaVersion } from './postgres-runtime.js';
|
|
5
|
+
export type { PostgresConnection } from './postgres-runtime.js';
|
|
4
6
|
/** Targeted provider aggregates. Cryptography and human/session verification run outside its transactions. */
|
|
5
7
|
export declare class PostgresProviderStore implements ProviderStore {
|
|
6
8
|
readonly namespace: string;
|
|
@@ -11,5 +13,7 @@ export declare class PostgresProviderStore implements ProviderStore {
|
|
|
11
13
|
rowsRead: number;
|
|
12
14
|
rowsWritten: number;
|
|
13
15
|
};
|
|
16
|
+
/** Only bounded SQL/crypto work; connection comes from the host's active transaction. */
|
|
17
|
+
withTransaction<T>(connection: PostgresConnection, action: () => Promise<T>): Promise<T>;
|
|
14
18
|
transaction<T>(selection: ProviderSelection, action: (tx: ProviderTransaction) => T): Promise<T>;
|
|
15
19
|
}
|
|
@@ -11,6 +11,8 @@ export class PostgresProviderStore {
|
|
|
11
11
|
}
|
|
12
12
|
close() { return this.db.close(); }
|
|
13
13
|
get metrics() { return this.db.metrics; }
|
|
14
|
+
/** Only bounded SQL/crypto work; connection comes from the host's active transaction. */
|
|
15
|
+
withTransaction(connection, action) { return this.db.withConnection(connection, action); }
|
|
14
16
|
transaction(selection, action) {
|
|
15
17
|
return this.db.run(async () => {
|
|
16
18
|
const rows = new Map();
|
package/dist/provider.d.ts
CHANGED
|
@@ -50,7 +50,7 @@ export interface ResultCipher {
|
|
|
50
50
|
open(value: string): string;
|
|
51
51
|
}
|
|
52
52
|
export declare function createResultCipher(key: Uint8Array): ResultCipher;
|
|
53
|
-
export type ProviderErrorCode = 'unauthorized' | 'binding_conflict' | 'expired' | 'consumed' | 'canceled' | 'rate_limited' | 'unavailable';
|
|
53
|
+
export type ProviderErrorCode = 'unauthorized' | 'binding_conflict' | 'expired' | 'consumed' | 'canceled' | 'rate_limited' | 'unavailable' | 'invalid_response';
|
|
54
54
|
export declare class ProviderError extends Error {
|
|
55
55
|
readonly code: ProviderErrorCode;
|
|
56
56
|
readonly retryAt?: number | undefined;
|
|
@@ -101,7 +101,7 @@ export declare function createProvider(options: ProviderOptions): {
|
|
|
101
101
|
expiresAt: number;
|
|
102
102
|
}>;
|
|
103
103
|
callback(callbackUrl: string): Promise<{
|
|
104
|
-
status:
|
|
104
|
+
status: any;
|
|
105
105
|
}>;
|
|
106
106
|
takeResult(credential: string, input: Row): Promise<{
|
|
107
107
|
status: string;
|
package/dist/provider.js
CHANGED
|
@@ -63,7 +63,7 @@ export function createProvider(options) {
|
|
|
63
63
|
fail('unauthorized');
|
|
64
64
|
return { ...row, enrollmentId };
|
|
65
65
|
}
|
|
66
|
-
function operation(tx, enrollment, operationId) {
|
|
66
|
+
function operation(tx, enrollment, operationId, allowConsumed = false) {
|
|
67
67
|
const row = tx.get('operation', `${enrollment.enrollmentId}:${operationId}`);
|
|
68
68
|
if (!row)
|
|
69
69
|
fail('binding_conflict');
|
|
@@ -71,7 +71,7 @@ export function createProvider(options) {
|
|
|
71
71
|
fail('expired');
|
|
72
72
|
if (row.status === 'canceled')
|
|
73
73
|
fail('canceled');
|
|
74
|
-
if (row.status === 'consumed')
|
|
74
|
+
if (row.status === 'consumed' && !allowConsumed)
|
|
75
75
|
fail('consumed');
|
|
76
76
|
return row;
|
|
77
77
|
}
|
|
@@ -226,10 +226,10 @@ export function createProvider(options) {
|
|
|
226
226
|
},
|
|
227
227
|
async callback(callbackUrl) {
|
|
228
228
|
const url = new URL(callbackUrl), base = new URL(options.callbackUrl);
|
|
229
|
-
if (url.origin !== base.origin || url.pathname !== base.pathname || url.username || url.password || url.hash || [
|
|
229
|
+
if (url.origin !== base.origin || url.pathname !== base.pathname || url.username || url.password || url.hash || ['state', 'iss', 'code', 'error', 'error_description', 'error_uri'].some(key => url.searchParams.getAll(key).length > 1))
|
|
230
230
|
fail('binding_conflict');
|
|
231
231
|
const state = text(url.searchParams.get('state')), issuer = text(url.searchParams.get('iss'));
|
|
232
|
-
if (
|
|
232
|
+
if (url.searchParams.has('code') === url.searchParams.has('error') || !(url.searchParams.get('code') || url.searchParams.get('error')) || callbackUrl.length > 8192)
|
|
233
233
|
fail('binding_conflict');
|
|
234
234
|
return store.transaction({ kind: 'callback', stateHash: hash(state) }, tx => {
|
|
235
235
|
const lookup = tx.get('state', hash(state));
|
|
@@ -238,14 +238,14 @@ export function createProvider(options) {
|
|
|
238
238
|
const enrollment = tx.get('enrollment', lookup.enrollmentId);
|
|
239
239
|
if (!enrollment || enrollment.revoked)
|
|
240
240
|
fail('unauthorized');
|
|
241
|
-
const op = operation(tx, { enrollmentId: lookup.enrollmentId }, lookup.operationId);
|
|
241
|
+
const op = operation(tx, { enrollmentId: lookup.enrollmentId }, lookup.operationId, true);
|
|
242
242
|
if (op.binding.issuer !== issuer || op.binding.purpose !== 'direct')
|
|
243
243
|
fail('binding_conflict');
|
|
244
244
|
const fingerprint = hash(callbackUrl);
|
|
245
|
-
if (op.status === 'ready') {
|
|
245
|
+
if (op.status === 'ready' || op.status === 'consumed') {
|
|
246
246
|
if (op.resultHash !== fingerprint)
|
|
247
247
|
fail('binding_conflict');
|
|
248
|
-
return { status:
|
|
248
|
+
return { status: op.status };
|
|
249
249
|
}
|
|
250
250
|
tx.put('operation', `${lookup.enrollmentId}:${lookup.operationId}`, { ...op, status: 'ready', result: cipher.seal(callbackUrl), resultHash: fingerprint });
|
|
251
251
|
return { status: 'ready' };
|
|
@@ -300,6 +300,7 @@ export function createProvider(options) {
|
|
|
300
300
|
fail('binding_conflict');
|
|
301
301
|
const reader = request.body?.getReader();
|
|
302
302
|
let body = '', bytes = 0;
|
|
303
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
303
304
|
if (reader)
|
|
304
305
|
try {
|
|
305
306
|
for (;;) {
|
|
@@ -309,8 +310,12 @@ export function createProvider(options) {
|
|
|
309
310
|
bytes += part.value.length;
|
|
310
311
|
if (bytes > 16384)
|
|
311
312
|
fail('binding_conflict');
|
|
312
|
-
body +=
|
|
313
|
+
body += decoder.decode(part.value, { stream: true });
|
|
313
314
|
}
|
|
315
|
+
body += decoder.decode();
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
fail('binding_conflict');
|
|
314
319
|
}
|
|
315
320
|
finally {
|
|
316
321
|
await reader.cancel().catch(() => { });
|
|
@@ -340,7 +345,13 @@ export function createProviderClient(options) {
|
|
|
340
345
|
throw new Error('provider URL must not contain query/fragment');
|
|
341
346
|
async function call(action, input) {
|
|
342
347
|
const response = await options.transport.fetch(new Request(`${options.url.replace(/\/$/, '')}/${action}`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${await options.credential()}` }, body: JSON.stringify({ version: 1, installationId: options.installationId, ...input }) }));
|
|
343
|
-
|
|
348
|
+
let body;
|
|
349
|
+
try {
|
|
350
|
+
body = object(await response.json());
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
throw new ProviderError('invalid_response');
|
|
354
|
+
}
|
|
344
355
|
if (!response.ok)
|
|
345
356
|
throw new ProviderError(['unauthorized', 'binding_conflict', 'expired', 'consumed', 'canceled', 'rate_limited'].includes(body.error) ? body.error : 'unavailable', body.retryAt);
|
|
346
357
|
return body;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poppykit/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Personal Agent Protocol client SDK",
|
|
6
6
|
"repository": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"type": "module",
|
|
11
11
|
"engines": {
|
|
12
|
-
"node": ">=24.
|
|
12
|
+
"node": ">=24.21.0 <25"
|
|
13
13
|
},
|
|
14
14
|
"packageManager": "pnpm@11.25.0",
|
|
15
15
|
"scripts": {
|