@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 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.12.0, TypeScript 5.9.3 and @types/node 24.10.0.
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.12+ is required for the SQLite reference adapter.
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
- Version 0.3.0 is a release candidate until its publishing workflow completes.
11
+ This source contains unreleased repairs; final version selection and publication are pending.
10
12
 
11
- Requires Node **24.12.0+**, ESM and HTTPS. CI tests Node 24.12.0 on Linux, macOS and
12
- Windows; later majors are not separately certified.
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@0.3.0
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.12.0 and npm>=11.5.1.
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.12.0
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.12.0 runtime.
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.12+
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).
@@ -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
- // Redirect identity without the response parameters; origin alone would hide userinfo.
59
- const redirectShape = (url) => JSON.stringify([url.origin, url.username, url.password, url.pathname, [...url.searchParams]]);
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
- const result = await options.assertions.assert({ operationId, issuer, purpose });
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 new AuthFailure('provider_unavailable');
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
- const bound = await options.providerIdentity.identity(issuer);
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('provider_unavailable');
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
- // Newest first; reverse() keeps insertion order as the tie-break for equal timestamps.
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 || redirectShape(base) !== redirectShape(new URL(direct.redirectUri)) || params.iss !== direct.issuer)
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
- const result = await options.assertions.assert({ purpose: 'browser', operationId: randomId('op_'), issuer: connection.issuer, sessionId: connection.sessionId, returnTo });
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 new AuthFailure('provider_unavailable', connectionId);
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) {
@@ -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[]>;
@@ -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();
@@ -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: string;
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 || [...url.searchParams.keys()].some(key => !['state', 'iss', 'code', 'error'].includes(key)) || ['state', 'iss', 'code', 'error'].some(key => url.searchParams.getAll(key).length > 1))
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 (!!url.searchParams.get('code') === !!url.searchParams.get('error') || callbackUrl.length > 8192)
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: 'ready' };
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 += new TextDecoder().decode(part.value, { stream: true });
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
- const body = object(await response.json());
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.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.0"
12
+ "node": ">=24.21.0 <25"
13
13
  },
14
14
  "packageManager": "pnpm@11.25.0",
15
15
  "scripts": {