@poppykit/server 0.1.1 → 0.3.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 CHANGED
@@ -18,4 +18,13 @@ idempotency and reconciliation. Existing issuers implement `ExternalAuthorityAda
18
18
  and do not need the optional reference issuer. Your framework owns HTTPS routing,
19
19
  trusted public URLs, ingress limits, request cancellation and private-error handling.
20
20
 
21
+ Direct reference issuers now require `authorizationResultCipher` before
22
+ `beginAuthorization`; same-decision `completeAuthorization` recovers the encrypted
23
+ callback through its original validity window. See [AUTH.md](AUTH.md) for custody,
24
+ revocation and one-time exchange limits. No package subpath or driver contract changed.
25
+
21
26
  Run the [quickstart](examples/quickstart.mjs) without a listener or external service.
27
+
28
+ The optional Postgres entry point ships its adapters, `createPostgresPool`,
29
+ `postgresMigration`, `migratePostgres` and schema-version export; see [storage](STORAGE.md).
30
+ TypeScript Postgres consumers also install `@types/pg@8.23.1` as a development dependency.
package/AUTH.md CHANGED
@@ -12,6 +12,43 @@ The host authenticates the human and protects consent with its own browser
12
12
  session/CSRF controls; pass a high-entropy browser binding. Registered callback
13
13
  URLs must be HTTPS on the client metadata domain and exactly match registration.
14
14
 
15
+ Direct hosts must configure `authorizationResultCipher: { seal, open }` with
16
+ synchronous authenticated encryption and a durable protected key before calling
17
+ `beginAuthorization`. Hosts already using the optional client provider can reuse
18
+ its `createResultCipher` instance. Other hosts supply the same structural contract;
19
+ no client package or database driver is loaded by the reference issuer.
20
+
21
+ `completeAuthorization(id, browserBinding, decision)` commits one decision and
22
+ an encrypted callback receipt atomically. Repeating the same decision returns
23
+ the same URL after restart; changed browser/account/scopes/decision and stale
24
+ account revocation/reset fail. Approval recovery expires at the earlier of the
25
+ original authorization deadline and code expiry; denial uses the original deadline.
26
+ Recovery never mints a replacement code or extends consent. The host must retain
27
+ its authenticated owner/installation/session binding and chosen decision durably,
28
+ then reconcile delivery outside transactions. Never retry an uncertain code exchange.
29
+
30
+ `withCurrent` checks token, Session and grant expiry immediately before protected
31
+ actions, including after body reads and long-poll waits. Streams close at token
32
+ expiry and recheck current authority for each emitted frame; reconnect with fresh
33
+ authentication. `withAccepted` intentionally ignores admission-token expiry while
34
+ preserving the accepted scope ceiling, reset generation and configured revocation policy.
35
+
36
+ Account Token rotation retains all issued hashes through the fixed grant lifetime.
37
+ Replaying any ancestor revokes its entire lineage and signs its Sessions out;
38
+ rotation never extends the grant. Client authentication remains mandatory, while
39
+ Session Tokens can move to a new DPoP key as the protocol permits. Shared provider
40
+ signing does not bind a copied refresh token to one provider enrollment. Keep
41
+ tokens secret and serialize rotation; never retry a lost token response blindly.
42
+ Bounded database cleanup removes hashes only after their lineage is expired,
43
+ revoked or absent. No plaintext callback code is persisted.
44
+
45
+ Upgrade prerequisite: older issuers discarded ancestors beyond the previous hash.
46
+ Those missing hashes cannot be reconstructed by this update. Before public
47
+ enablement, revoke every pre-upgrade live lineage through the issuer's normal
48
+ revocation/disconnect flow and require fresh consent, or prove it has expired.
49
+ Until then, the full-ancestor guarantee applies only to newly created lineages.
50
+ This update does not reset stored data or silently revoke legacy grants.
51
+
15
52
  Enable reference Device sign-in with `device: { endpoint, verificationUri,
16
53
  scopes }`. Route the configured device endpoint to `deviceAuthorization`.
17
54
  On the human page call `beginDeviceAuthorization(userCode, browserBinding)` and
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ Require Node 24 >=24.22.1 within 24.x. Enforce current-token expiry after waits and
6
+ at streamed delivery while preserving already-accepted work's authority ceiling.
7
+ Deduplicate reserved string output IDs and retain refresh-token ancestors through
8
+ fixed grant expiry so replay revokes descendants. Direct Sign-In hosts must supply
9
+ the documented durable `authorizationResultCipher`; identical decisions recover
10
+ their original encrypted callback. Before public enablement, revoke pre-upgrade
11
+ live grant lineages and obtain fresh consent, or prove their fixed expiry.
12
+ Required release verification now includes isolated PostgreSQL contracts.
13
+
14
+ ## 0.2.0 (release candidate)
15
+
16
+ Add bundled Postgres company stores/coordinator with explicit migrations, atomic host transaction composition, savepoints and pool lifecycle.
17
+
3
18
  ## 0.1.1
4
19
 
5
20
  Documentation refresh: registry installation, runnable quickstarts, focused guides
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.22.1, 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/DELIVERY.md CHANGED
@@ -53,8 +53,9 @@ SQLite reconciliation; deployments can implement the same notification contract
53
53
  with their own distributed adapter. Lost/duplicate hints cannot erase a commit.
54
54
  SQLite event windows and append operations do not reload complete event histories.
55
55
 
56
- Server streams default to 30 seconds and apply pull backpressure, rechecking
57
- current authority before further pages. This bounded-connection policy omits
56
+ Server streams default to 30 seconds, end earlier at token expiry and apply pull
57
+ backpressure, rechecking current authority before every emitted frame. Reconnect
58
+ with a fresh credential and the last handled event ID. This bounded-connection policy omits
58
59
  periodic idle comments; proxies may close an idle stream sooner, and the client
59
60
  reconnects. Longer-lived deployments should supply heartbeat handling at their
60
61
  HTTP adapter. Hosts must propagate disconnect/AbortSignal and body cancellation.
package/README.md CHANGED
@@ -6,12 +6,14 @@ supplies accounts, consent UI, agent, business tools and HTTPS routing. [MIT](LI
6
6
 
7
7
  ## Install
8
8
 
9
- Requires Node **24.12.0+** and ESM. CI tests Node 24.12.0 on Linux, macOS and Windows;
10
- later majors are not separately certified. SQLite is optional/experimental and
9
+ This source contains unreleased repairs; final version selection and publication are pending.
10
+
11
+ Requires Node **24.x, at least 24.22.1**, and ESM. CI tests Node 24.22.1 on Linux, macOS and Windows;
12
+ other Node majors are unsupported. SQLite is optional/experimental and
11
13
  requires local filesystem locking.
12
14
 
13
15
  ```sh
14
- npm install @poppykit/server@0.1.1
16
+ npm install @poppykit/server
15
17
  ```
16
18
 
17
19
  ## Configure a service
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.22.1) 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,10 +1,10 @@
1
1
  # Company persistence and authorization
2
2
 
3
- Request paths use targeted domain selections, not whole-state rewrites. Node 24.12.0 or later is required.
3
+ Request paths use targeted domain selections, not whole-state rewrites. Node 24.x, at least 24.22.1, is required.
4
4
  The optional `@poppykit/server/sqlite` entry point exports `SqliteCompanyDatabase`.
5
5
  The core and reference issuer entry points do not import SQLite. Consumers can
6
6
  implement `ConversationStore`, `IssuerStore` and `AuthorityCoordinator` themselves.
7
- The tested Node 24.12.0 runtime marks `node:sqlite` experimental.
7
+ The tested Node 24.22.1 runtime marks `node:sqlite` experimental.
8
8
 
9
9
  ```ts
10
10
  import { SqliteCompanyDatabase } from '@poppykit/server/sqlite';
@@ -35,7 +35,7 @@ with correct SQLite locks; network-mounted multi-host SQLite is unsupported.
35
35
  ## Domain records and guarantees
36
36
 
37
37
  Every primary key includes a namespace (use the exact full issuer). Sessions,
38
- grants, current/previous token hashes, authorization requests, codes, access-token
38
+ grants, every live-lineage Account Token hash, authorization requests, codes, access-token
39
39
  hashes, replay keys, conversation metadata, shared account bindings, message
40
40
  receipts and work intent are separate records. Ordered events are a separate table
41
41
  keyed by namespace/conversation/sequence. Indexes cover client, account, grant,
@@ -71,7 +71,9 @@ its own idempotency key (such as the stable work ID), or the application must re
71
71
  an uncertain result for reconciliation. Never claim universal exactly-once execution.
72
72
 
73
73
  `cleanup(namespace, now, limit)` bounds deletion to at most 1000 expired short-lived
74
- codes, authorizations, access tokens or replay entries. It does not delete grants,
74
+ codes, authorizations, access tokens, replay entries or expired/revoked/orphaned
75
+ Account Token hashes. Every used hash for a live lineage remains replay-detectable
76
+ through its fixed expiry; retained size follows rotation volume. It does not delete grants,
75
77
  work intent, message receipts or conversation events. Receipt identity remains
76
78
  through retention for the conversation lifetime. Finished work pruning is
77
79
  per selected conversation binding group; unrelated conversation growth cannot
@@ -110,3 +112,71 @@ state. Default payload retention is indefinite; operators should retain at least
110
112
  30 days and explicitly choose any shorter policy. See [DELIVERY.md](DELIVERY.md)
111
113
  for distinct lifetimes and client recovery behavior. No automatic destructive
112
114
  reset or history deletion was added.
115
+
116
+ ## Postgres
117
+
118
+ `@poppykit/server/postgres` ships `PostgresCompanyDatabase`, explicit migrations,
119
+ and `createPostgresPool` in this package. Install optional peer `pg@8.23.1` when
120
+ using that subpath. Core/reference-auth imports load neither database driver.
121
+ Supported baseline: Node 24.x (at least 24.22.1), PostgreSQL 17, including Supabase transaction pooling.
122
+ No application, Google login or Supabase Auth dependency is required.
123
+
124
+ ```ts
125
+ import { createPostgresPool, migratePostgres,
126
+ PostgresCompanyDatabase } from '@poppykit/server/postgres';
127
+ const pool = createPostgresPool({ connectionString: process.env.DATABASE_URL });
128
+ // Explicit operator/deployment migration; do this before handling requests.
129
+ await migratePostgres(pool, 'poppy_server');
130
+ const database = new PostgresCompanyDatabase(pool);
131
+ const issuerStore = database.issuer('https://company.example/issuer');
132
+ const conversationStore = database.conversations('https://company.example/issuer');
133
+ // Reference issuer: store: issuerStore, coordinator: database.
134
+ // Conversation service: store: conversationStore, policy: issuer.authority.policy.
135
+ await database.close(); // after in-flight requests end; leaves caller pool open
136
+ await pool.end();
137
+ ```
138
+
139
+ Use one database object for issuer/coordinator/conversations. `withTransaction`
140
+ passes a generic query connection to host DB work inside that same transaction:
141
+
142
+ ```ts
143
+ await issuer.authority.policy.withCurrent(principal, ['records:write'], () =>
144
+ database.withTransaction(async connection => {
145
+ await connection.query('UPDATE application.records SET value=$1 WHERE owner=$2',
146
+ [validatedValue, trustedAccountId]);
147
+ }));
148
+ ```
149
+
150
+ The policy holds the issuer authority fence through commit; revocation waits or
151
+ wins before mutation. The host must still validate ownership, payload digests,
152
+ current business revisions and its own idempotency receipts. Issuer namespace
153
+ metadata and per-conversation binding/message locks use transaction-scoped advisory
154
+ locks, including absent keys; no process mutex or session lock substitutes for them.
155
+ Nested mutations use savepoints. Await nested mutations sequentially: overlapping
156
+ siblings reject explicitly, while recursion is supported. Escaped query handles
157
+ reject after their scope ends. External model/network/KMS calls belong outside this
158
+ boundary. Fences do not make arbitrary external effects exactly-once.
159
+
160
+ Pool defaults are max 2, acquisition 3s/idle 10s, verified TLS; configure a trusted
161
+ CA through `ssl`, never insecure TLS URL overrides. Explicit `ssl:false` is for a
162
+ trusted local test server. Caller-created pools remain their owner's responsibility.
163
+ Transactions/statements are bounded to 5s, lock waits 2s, with unnamed parameterized
164
+ queries and one checkout from BEGIN to COMMIT/ROLLBACK. No LISTEN, named prepares
165
+ or session state is required. `close` never closes a caller-supplied pool; callers
166
+ of `createPostgresPool` own and must end that pool.
167
+
168
+ Immutable SQL ships at `migrations/postgres/0001_poppy_server.sql`.
169
+ `postgresMigration('poppy_server')` returns reviewable SQL and
170
+ `migratePostgres(pool,'poppy_server')` is the explicit atomic apply/version check.
171
+ No constructor/request-time DDL or automatic SQLite import/reset exists. Unknown or
172
+ missing versions fail closed. Version 1 does not certify arbitrary manual table
173
+ edits: inspect migration history and reconcile drift before applying new versions.
174
+ Applied migrations are immutable, corrections require new versioned files. Back up,
175
+ review and rehearse live changes; roll application code back only to compatible
176
+ schemas, or restore a reviewed backup for destructive reversal. No populated-schema
177
+ drop/downgrade is automated. The disposable test runner is development tooling,
178
+ never a production migration command.
179
+
180
+ `runIssuerAdapterContract` joins the existing reusable conversation contract in
181
+ `@poppykit/server/adapter-contract`. Run both against two fresh shared handles;
182
+ process death, lock ordering and DB durability still require backend-specific tests.
@@ -1,4 +1,12 @@
1
1
  import type { ConversationOptions } from './conversation/service.js';
2
+ import type { IssuerStore } from './reference-auth/store.js';
3
+ export type IssuerAdapterFixture = () => Promise<{
4
+ store: IssuerStore;
5
+ second: IssuerStore;
6
+ close(): Promise<void>;
7
+ }>;
8
+ /** A fresh shared namespace, including absent-row serialization; no vendor-specific fixture is required. */
9
+ export declare function runIssuerAdapterContract(fixture: IssuerAdapterFixture): void;
2
10
  type Store = ConversationOptions['store'] & {
3
11
  inspect(): Promise<import('./conversation/service.js').ConversationStoreState>;
4
12
  adminTransaction<T>(change: (draft: import('./conversation/service.js').ConversationStoreState) => T): Promise<T>;
@@ -5,6 +5,24 @@ import test from 'node:test';
5
5
  import assert from 'node:assert/strict';
6
6
  import { AuthorityError } from './auth/authority.js';
7
7
  import { createConversationService, resetConversationState } from './conversation/service.js';
8
+ /** A fresh shared namespace, including absent-row serialization; no vendor-specific fixture is required. */
9
+ export function runIssuerAdapterContract(fixture) {
10
+ test('issuer adapter contract: replay updates serialize, snapshots detach and failed callbacks roll back', async () => {
11
+ const f = await fixture(), selection = { kind: 'browser', sessionId: 'absent', replayKey: 'counter' };
12
+ try {
13
+ await Promise.all(Array.from({ length: 10 }, (_, i) => (i % 2 ? f.store : f.second).transaction(selection, state => { state.replay.counter = (state.replay.counter ?? 0) + 1; })));
14
+ const state = await f.store.read(selection);
15
+ assert.equal(state.replay.counter, 10);
16
+ state.replay.counter = 999;
17
+ await assert.rejects(f.store.transaction(selection, draft => { draft.replay.counter = 0; throw new Error('rollback'); }), /rollback/);
18
+ await assert.rejects(f.store.transaction(selection, async (draft) => { draft.replay.counter = 0; }));
19
+ assert.equal((await f.second.read(selection)).replay.counter, 10);
20
+ }
21
+ finally {
22
+ await f.close();
23
+ }
24
+ });
25
+ }
8
26
  const endpoint = 'https://contract.example.test/poppy/conversations';
9
27
  const base = { issuer: 'https://auth.contract.test', clientId: 'https://agent.contract.test/client', sessionId: 's1', userId: 'u_1', scopes: [], tokenExpiresAt: 9e12, sessionVersion: 1 };
10
28
  const principals = {
@@ -32,7 +50,7 @@ function serviceOn(store, clock) {
32
50
  return principal;
33
51
  },
34
52
  };
35
- return createConversationService({ endpoint, store, authenticator, policy, clock, leaseMs: 1000 });
53
+ return createConversationService({ endpoint, store, authenticator, policy, clock, leaseMs: 1000, supportedScopes: ['poppy:read'] });
36
54
  }
37
55
  async function call(service, path, as, body, query = '') {
38
56
  const response = await service.handle(new Request(`${endpoint}${path}${query}`, {
@@ -51,6 +69,18 @@ export function runConversationAdapterContract(fixture) {
51
69
  await opened.close();
52
70
  }
53
71
  }
72
+ test('adapter contract: concurrent conflicting output retries publish one immutable result', () => withFixture(async ({ store, second }) => {
73
+ const a = serviceOn(store, clock), b = serviceOn(second, clock);
74
+ const id = (await call(a, '', 'signedOut', { message })).body.conversation_id;
75
+ const work = (await a.claim());
76
+ const results = await Promise.allSettled([a.reply(work, 'same-call', reply), b.reply(work, 'same-call', { ...reply, text: 'conflicting result' })]);
77
+ assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
78
+ const failed = results.find(result => result.status === 'rejected');
79
+ assert.ok(failed && failed.status === 'rejected');
80
+ assert.equal(failed.reason.code, 'output_conflict');
81
+ const events = (await call(a, `/${id}/events`, 'signedOut')).body.events;
82
+ assert.equal(events.filter(event => event.message?.id === reply.id).length, 1);
83
+ }));
54
84
  test('adapter contract: reads and results are detached; failed, non-JSON and async changes roll back', () => withFixture(async ({ store, second }) => {
55
85
  const snapshot = await store.inspect();
56
86
  snapshot.bindings.leaked = {};
@@ -115,6 +145,33 @@ export function runConversationAdapterContract(fixture) {
115
145
  const events = (await call(b, `/${id}/events`, 'signedOut')).body.events;
116
146
  assert.equal(events.filter(event => event.message?.id === reply.id).length, 1);
117
147
  }));
148
+ test('adapter contract: reserved output IDs persist atomic reply and prompt receipts across handles', () => withFixture(async ({ store, second }) => {
149
+ const a = serviceOn(store, clock);
150
+ const id = (await call(a, '', 'signedOut', { message })).body.conversation_id;
151
+ const work = (await a.claim());
152
+ for (const outputId of ['__proto__', 'constructor', 'ordinary']) {
153
+ const b = serviceOn(second, clock);
154
+ const value = { ...reply, id: `reply_${outputId}` };
155
+ await a.reply(work, outputId, value);
156
+ await b.reply(work, outputId, value);
157
+ await assert.rejects(b.reply(work, outputId, { ...value, text: 'changed' }), { code: 'output_conflict' });
158
+ await a.requestAuthorization(work, `auth_${outputId}`, ['poppy:read']);
159
+ await b.requestAuthorization(work, `auth_${outputId}`, ['poppy:read']);
160
+ await a.requestUser(work, `user_${outputId}`, outputId);
161
+ await b.requestUser(work, `user_${outputId}`, outputId);
162
+ await assert.rejects(b.requestUser(work, `user_${outputId}`, 'changed'), { code: 'output_conflict' });
163
+ const persisted = await second.inspect();
164
+ const receipt = persisted.work.find(row => row.id === work.workId);
165
+ assert.ok(Object.hasOwn(receipt.outputs, outputId));
166
+ }
167
+ const events = (await call(serviceOn(second, clock), `/${id}/events`, 'signedOut')).body.events;
168
+ assert.equal(events.filter(event => event.message?.role === 'company').length, 3);
169
+ assert.equal(events.filter(event => event.type === 'authorization').length, 3);
170
+ assert.equal(events.filter(event => event.type === 'user_requested').length, 3);
171
+ clock.t += 1000;
172
+ await serviceOn(second, clock).claim();
173
+ await assert.rejects(a.reply(work, '__proto__', reply), { code: 'stale_work' });
174
+ }));
118
175
  test('adapter contract: a waited POST committed before a crash is recovered unchanged on the other handle', () => withFixture(async ({ store, second }) => {
119
176
  const crashing = serviceOn({ read: () => store.inspect(), transaction: (_selection, change) => store.adminTransaction(change).then(() => { throw new Error('crash after commit'); }) }, clock);
120
177
  await assert.rejects(call(crashing, '', 'signedOut', { message }, '?wait=10'), { message: 'crash after commit' });
@@ -391,7 +391,7 @@ export function createConversationService(options) {
391
391
  if (!streaming) {
392
392
  const deadline = Date.now() + read.waitSeconds * 1000, logicalDeadline = clock.now() + read.waitSeconds * 1000;
393
393
  while (!first.events.length && Date.now() < deadline && clock.now() < logicalDeadline && !signal.aborted) {
394
- await wait(Math.min(pollIntervalMs, deadline - Date.now(), logicalDeadline - clock.now()), seen);
394
+ await wait(Math.min(pollIntervalMs, deadline - Date.now(), logicalDeadline - clock.now(), principal.tokenExpiresAt - clock.now()), seen);
395
395
  seen = revision;
396
396
  if (signal.aborted)
397
397
  throw signal.reason;
@@ -401,12 +401,18 @@ export function createConversationService(options) {
401
401
  cleanup();
402
402
  return json(200, first);
403
403
  }
404
- lifetime = setTimeout(cleanup, streamMs);
405
- const encoder = new TextEncoder(), deadline = Date.now() + streamMs;
404
+ const duration = Math.max(0, Math.min(streamMs, principal.tokenExpiresAt - clock.now()));
405
+ lifetime = setTimeout(cleanup, duration);
406
+ const encoder = new TextEncoder(), deadline = Date.now() + duration;
406
407
  let pending = first, index = 0, bytes = 0, cancelled = false;
407
408
  const stream = new ReadableStream({
408
409
  async pull(target) {
409
410
  try {
411
+ if (clock.now() >= principal.tokenExpiresAt) {
412
+ cleanup();
413
+ target.close();
414
+ return;
415
+ }
410
416
  signal.throwIfAborted();
411
417
  while (index >= pending.events.length) {
412
418
  if (Date.now() >= deadline || pending.status === 'closed' && !pending.has_more) {
@@ -421,6 +427,7 @@ export function createConversationService(options) {
421
427
  pending = await deliver();
422
428
  index = 0;
423
429
  }
430
+ await policy.withCurrent(principal, [], async () => { });
424
431
  const event = pending.events[index++];
425
432
  const frame = encoder.encode('id: ' + event.id + '\ndata: ' + JSON.stringify(event) + '\n\n');
426
433
  bytes += frame.byteLength;
@@ -433,7 +440,7 @@ export function createConversationService(options) {
433
440
  target.enqueue(frame);
434
441
  }
435
442
  catch (error) {
436
- const expired = stop.signal.aborted && !request.signal.aborted;
443
+ const expired = (stop.signal.aborted || clock.now() >= principal.tokenExpiresAt) && !request.signal.aborted;
437
444
  cleanup();
438
445
  if (cancelled)
439
446
  return;
@@ -570,7 +577,7 @@ export function createConversationService(options) {
570
577
  function firstOutput(work, outputId, payload) {
571
578
  const previous = own(work.outputs, outputId);
572
579
  if (previous === undefined) {
573
- work.outputs[outputId] = payload;
580
+ work.outputs = { ...work.outputs, [outputId]: payload };
574
581
  return true;
575
582
  }
576
583
  if (previous !== payload)
@@ -0,0 +1,30 @@
1
+ import type { Pool, PoolConfig, QueryResult, QueryResultRow } from 'pg';
2
+ export type PostgresSchema = 'poppy_client' | 'poppy_provider' | 'poppy_server';
3
+ export type PostgresConnection = {
4
+ query<R extends QueryResultRow = QueryResultRow>(sql: string, values?: unknown[]): Promise<QueryResult<R>>;
5
+ };
6
+ export declare function json(value: unknown): string;
7
+ export declare function synchronous<T>(value: T): T;
8
+ /** Remote defaults verify TLS; plaintext must be explicitly selected for a trusted local test server. */
9
+ export declare function createPostgresPool(config: PoolConfig): Pool;
10
+ export declare const postgresSchemaVersion = 1;
11
+ export declare function postgresMigration(schema: PostgresSchema): Promise<string>;
12
+ /** Explicit administration only. Constructors never execute DDL. */
13
+ export declare function migratePostgres(pool: Pool, schema: PostgresSchema): Promise<void>;
14
+ export declare class PostgresRuntime {
15
+ readonly pool: Pool;
16
+ readonly schema: PostgresSchema;
17
+ private readonly ownsPool;
18
+ private readonly context;
19
+ private closed;
20
+ readonly metrics: {
21
+ rowsRead: number;
22
+ rowsWritten: number;
23
+ };
24
+ constructor(pool: Pool, schema: PostgresSchema, ownsPool?: boolean);
25
+ close(): Promise<void>;
26
+ run<T>(action: () => Promise<T>): Promise<T>;
27
+ connection(): PostgresConnection;
28
+ query<R extends QueryResultRow = QueryResultRow>(sql: string, values?: unknown[]): Promise<R[]>;
29
+ lock(key: string): Promise<void>;
30
+ }
@@ -0,0 +1,172 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { readFile } from 'node:fs/promises';
3
+ import pg from 'pg';
4
+ export function json(value) {
5
+ return JSON.stringify(value, (_key, item) => {
6
+ if (item === undefined || typeof item === 'function' || typeof item === 'bigint' || (typeof item === 'number' && !Number.isFinite(item)))
7
+ throw new TypeError('non-JSON record');
8
+ return item;
9
+ });
10
+ }
11
+ export function synchronous(value) {
12
+ if (value && typeof value.then === 'function')
13
+ throw new TypeError('transaction callback must be synchronous');
14
+ return structuredClone(value);
15
+ }
16
+ /** Remote defaults verify TLS; plaintext must be explicitly selected for a trusted local test server. */
17
+ export function createPostgresPool(config) {
18
+ if (config.ssl && typeof config.ssl === 'object' && config.ssl.rejectUnauthorized === false)
19
+ throw new TypeError('Postgres TLS verification is required');
20
+ if (config.connectionString && [...new URL(config.connectionString).searchParams.keys()].some(key => key.toLowerCase().startsWith('ssl') || key === 'uselibpqcompat'))
21
+ throw new TypeError('configure verified TLS through ssl, not connection-string overrides');
22
+ return new pg.Pool({ max: 2, connectionTimeoutMillis: 3000, idleTimeoutMillis: 10000, ...config, ssl: config.ssl ?? { rejectUnauthorized: true } });
23
+ }
24
+ export const postgresSchemaVersion = 1;
25
+ function schemaName(schema) {
26
+ if (!['poppy_client', 'poppy_provider', 'poppy_server'].includes(schema))
27
+ throw new TypeError('unsupported Postgres schema');
28
+ return schema;
29
+ }
30
+ export async function postgresMigration(schema) {
31
+ schemaName(schema);
32
+ return readFile(new URL(`../migrations/postgres/0001_${schema}.sql`, import.meta.url), 'utf8');
33
+ }
34
+ /** Explicit administration only. Constructors never execute DDL. */
35
+ export async function migratePostgres(pool, schema) {
36
+ schemaName(schema);
37
+ const client = await pool.connect();
38
+ try {
39
+ await client.query('BEGIN');
40
+ await client.query("SET LOCAL statement_timeout='5s'");
41
+ await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1,0))', [`poppykit:migrate:${schema}`]);
42
+ const exists = (await client.query('SELECT to_regclass($1) AS name', [`${schema}.schema_version`])).rows[0].name;
43
+ if (exists) {
44
+ const rows = (await client.query(`SELECT version FROM ${schema}.schema_version`)).rows;
45
+ if (rows.length !== 1 || rows[0].version !== postgresSchemaVersion)
46
+ throw new Error('unsupported Postgres schema version');
47
+ }
48
+ else
49
+ await client.query(await postgresMigration(schema));
50
+ await client.query('COMMIT');
51
+ }
52
+ catch (error) {
53
+ await client.query('ROLLBACK');
54
+ throw error;
55
+ }
56
+ finally {
57
+ client.release();
58
+ }
59
+ }
60
+ function scopeActive(context) {
61
+ for (let scope = context; scope; scope = scope.parent)
62
+ if (!scope.active || !scope.state.active)
63
+ return false;
64
+ return true;
65
+ }
66
+ export class PostgresRuntime {
67
+ pool;
68
+ schema;
69
+ ownsPool;
70
+ context = new AsyncLocalStorage();
71
+ closed = false;
72
+ metrics = { rowsRead: 0, rowsWritten: 0 };
73
+ constructor(pool, schema, ownsPool = false) {
74
+ this.pool = pool;
75
+ this.schema = schema;
76
+ this.ownsPool = ownsPool;
77
+ }
78
+ async close() { this.closed = true; if (this.ownsPool)
79
+ await this.pool.end(); }
80
+ async run(action) {
81
+ if (this.closed)
82
+ throw new Error('Postgres adapter is closed');
83
+ const parent = this.context.getStore();
84
+ if (parent) {
85
+ if (!scopeActive(parent))
86
+ throw new Error('expired Postgres transaction');
87
+ if (parent.childActive)
88
+ throw new Error('concurrent nested Postgres transactions are unsupported; await each mutation');
89
+ parent.childActive = true;
90
+ const savepoint = `nested_${++parent.state.serial}`;
91
+ const child = { ...parent, parent, active: true, childActive: false, locks: new Set(parent.locks) };
92
+ let started = false;
93
+ try {
94
+ await parent.client.query(`SAVEPOINT ${savepoint}`);
95
+ started = true;
96
+ const result = await this.context.run(child, action);
97
+ if (!scopeActive(child))
98
+ throw new Error('expired Postgres transaction');
99
+ if (child.childActive)
100
+ throw new Error('unawaited nested Postgres transaction');
101
+ child.active = false;
102
+ await parent.client.query(`RELEASE SAVEPOINT ${savepoint}`);
103
+ parent.locks = child.locks;
104
+ return result;
105
+ }
106
+ catch (error) {
107
+ child.active = false;
108
+ if (started && scopeActive(parent)) {
109
+ await parent.client.query(`ROLLBACK TO SAVEPOINT ${savepoint}`);
110
+ await parent.client.query(`RELEASE SAVEPOINT ${savepoint}`);
111
+ }
112
+ throw error;
113
+ }
114
+ finally {
115
+ child.active = false;
116
+ parent.childActive = false;
117
+ }
118
+ }
119
+ const client = await this.pool.connect(), context = { client, active: true, locks: new Set(), state: { serial: 0, active: true }, childActive: false };
120
+ try {
121
+ await client.query('BEGIN');
122
+ await client.query("SET LOCAL statement_timeout='5s'");
123
+ await client.query("SET LOCAL lock_timeout='2s'");
124
+ await client.query("SET LOCAL idle_in_transaction_session_timeout='5s'");
125
+ await client.query("SET LOCAL transaction_timeout='5s'");
126
+ const versions = (await client.query(`SELECT version FROM ${this.schema}.schema_version`)).rows;
127
+ if (versions.length !== 1 || versions[0].version !== postgresSchemaVersion)
128
+ throw new Error('unsupported Postgres schema version');
129
+ const result = await this.context.run(context, action);
130
+ if (context.childActive)
131
+ throw new Error('unawaited nested Postgres transaction');
132
+ await client.query('COMMIT');
133
+ return result;
134
+ }
135
+ catch (error) {
136
+ context.state.active = false;
137
+ await client.query('ROLLBACK').catch(() => { });
138
+ throw error;
139
+ }
140
+ finally {
141
+ context.active = false;
142
+ context.state.active = false;
143
+ client.release();
144
+ }
145
+ }
146
+ connection() {
147
+ const context = this.context.getStore();
148
+ if (!context || !scopeActive(context))
149
+ throw new Error('no active Postgres transaction');
150
+ return { query: async (sql, values) => {
151
+ if (!scopeActive(context) || this.context.getStore() !== context)
152
+ throw new Error('expired Postgres transaction');
153
+ if (context.childActive)
154
+ throw new Error('concurrent Postgres transaction use is unsupported; await each mutation');
155
+ return context.client.query(sql, values);
156
+ } };
157
+ }
158
+ async query(sql, values = []) {
159
+ const result = await this.connection().query(sql, values);
160
+ this.metrics.rowsRead += result.rows.length;
161
+ return result.rows;
162
+ }
163
+ async lock(key) {
164
+ const context = this.context.getStore();
165
+ if (!context || !scopeActive(context))
166
+ throw new Error('no active Postgres transaction');
167
+ if (!context.locks.has(key)) {
168
+ await this.query('SELECT pg_advisory_xact_lock(hashtextextended($1,0))', [`${this.schema}:${key}`]);
169
+ context.locks.add(key);
170
+ }
171
+ }
172
+ }
@@ -0,0 +1,31 @@
1
+ import type { Pool } from 'pg';
2
+ import type { ConversationStore } from './conversation/store.js';
3
+ import type { IssuerStore } from './reference-auth/store.js';
4
+ import type { PostgresConnection } from './postgres-runtime.js';
5
+ export { createPostgresPool, migratePostgres, postgresMigration, postgresSchemaVersion } from './postgres-runtime.js';
6
+ export type { PostgresConnection } from './postgres-runtime.js';
7
+ /** Share this coordinator between the issuer, conversation service and host business mutations. */
8
+ export declare class PostgresCompanyDatabase {
9
+ private readonly db;
10
+ constructor(pool: Pool);
11
+ get metrics(): {
12
+ rowsRead: number;
13
+ rowsWritten: number;
14
+ };
15
+ close(): Promise<void>;
16
+ run<T>(action: () => Promise<T>): Promise<T>;
17
+ withTransaction<T>(action: (connection: PostgresConnection) => Promise<T>): Promise<T>;
18
+ private atomic;
19
+ private sqlAll;
20
+ private sqlGet;
21
+ private sqlRun;
22
+ private rows;
23
+ private one;
24
+ private put;
25
+ private save;
26
+ private meta;
27
+ conversations(namespace: string): ConversationStore;
28
+ issuer(namespace: string): IssuerStore;
29
+ /** Bounded maintenance; never deletes message receipts, events, grants or work intent. */
30
+ cleanup(namespace: string, now: number, limit?: number): Promise<number>;
31
+ }
@@ -0,0 +1,304 @@
1
+ import { initialConversationState } from './conversation/service.js';
2
+ import { initialIssuerState } from './reference-auth/index.js';
3
+ import { clientRevisionKey, accountRevisionKey } from './reference-auth/store.js';
4
+ import { PostgresRuntime, json } from './postgres-runtime.js';
5
+ export { createPostgresPool, migratePostgres, postgresMigration, postgresSchemaVersion } from './postgres-runtime.js';
6
+ /** Share this coordinator between the issuer, conversation service and host business mutations. */
7
+ export class PostgresCompanyDatabase {
8
+ db;
9
+ constructor(pool) { this.db = new PostgresRuntime(pool, 'poppy_server'); }
10
+ get metrics() { return this.db.metrics; }
11
+ close() { return this.db.close(); }
12
+ run(action) { return this.db.run(action); }
13
+ withTransaction(action) { return this.run(() => action(this.db.connection())); }
14
+ atomic(action) { return this.db.run(action); }
15
+ sqlAll(sql, values = []) { return this.db.query(sql, values); }
16
+ async sqlGet(sql, values = []) { return (await this.sqlAll(sql, values))[0]; }
17
+ async sqlRun(sql, values = []) { const result = await this.db.connection().query(sql, values); return { changes: result.rowCount ?? 0 }; }
18
+ async rows(ns, kind, where = '', params = []) { return this.db.query(`SELECT id,body FROM poppy_server.records WHERE ns=$1 AND kind=$2 ${where}`, [ns, kind, ...params]); }
19
+ async one(ns, kind, id) { const row = (await this.rows(ns, kind, 'AND id=$3', [id]))[0]; return row ? JSON.parse(row.body) : undefined; }
20
+ async put(ns, kind, id, value) { await this.sqlRun('INSERT INTO poppy_server.records(ns,kind,id,body) VALUES($1,$2,$3,$4) ON CONFLICT(ns,kind,id) DO UPDATE SET body=excluded.body', [ns, kind, id, json(value)]); this.metrics.rowsWritten++; }
21
+ async save(ns, kind, before, after) {
22
+ for (const id of Object.keys(before))
23
+ if (!Object.hasOwn(after, id)) {
24
+ await this.sqlRun('DELETE FROM poppy_server.records WHERE ns=$1 AND kind=$2 AND id=$3', [ns, kind, id]);
25
+ this.metrics.rowsWritten++;
26
+ }
27
+ for (const [id, value] of Object.entries(after))
28
+ if (!Object.hasOwn(before, id) || json(before[id]) !== json(value))
29
+ await this.put(ns, kind, id, value);
30
+ }
31
+ async meta(ns, kind, initial) {
32
+ const found = await this.one(ns, kind, 'meta');
33
+ if (found)
34
+ return found;
35
+ await this.db.lock(JSON.stringify([ns, kind, 'meta']));
36
+ const raced = await this.one(ns, kind, 'meta');
37
+ if (raced)
38
+ return raced;
39
+ const value = initial();
40
+ await this.put(ns, kind, 'meta', value);
41
+ return value;
42
+ }
43
+ conversations(namespace) {
44
+ const load = async (selection) => {
45
+ const state = { ...await this.meta(namespace, 'conversation_meta', () => { const { version, generation } = initialConversationState(); return { version, generation }; }), conversations: {}, bindings: {}, messages: {}, work: [] };
46
+ if (selection.messageKey)
47
+ await this.db.lock(JSON.stringify([namespace, 'message', selection.messageKey]));
48
+ let id = selection.conversationId;
49
+ if (selection.messageKey) {
50
+ const message = await this.one(namespace, 'messages', selection.messageKey);
51
+ if (message) {
52
+ state.messages[selection.messageKey] = message;
53
+ id = message.conversationId;
54
+ }
55
+ }
56
+ if (selection.workId)
57
+ id = (await this.one(namespace, 'work', selection.workId))?.conversationId;
58
+ if (selection.claimAt !== undefined) {
59
+ const row = await this.sqlGet(`SELECT w.body FROM poppy_server.records w JOIN poppy_server.records c ON c.ns=w.ns AND c.kind='conversations' AND c.id=(w.body::jsonb#>>'{conversationId}')
60
+ WHERE w.ns=$1 AND w.kind='work' AND (w.body::jsonb#>>'{phase}') IN ('pending','claimed','held')
61
+ AND (c.body::jsonb#>>'{childId}') IS NULL AND (
62
+ ((c.body::jsonb#>>'{responder}')='human' AND coalesce(((c.body::jsonb#>>'{humanUntil}')::double precision),0)<=$2) OR
63
+ ((c.body::jsonb#>>'{responder}')!='human' AND (c.body::jsonb#>>'{status}')!='queued' AND (w.body::jsonb#>>'{phase}') IN ('pending','claimed')
64
+ AND ((w.body::jsonb#>>'{phase}')='pending' OR coalesce(((w.body::jsonb#>>'{leaseUntil}')::double precision),0)<=$3)
65
+ AND NOT EXISTS (SELECT 1 FROM poppy_server.records earlier WHERE earlier.ns=w.ns AND earlier.kind='work' AND earlier.ordinal<w.ordinal
66
+ AND (earlier.body::jsonb#>>'{conversationId}')=(w.body::jsonb#>>'{conversationId}') AND (earlier.body::jsonb#>>'{phase}') IN ('pending','claimed'))))
67
+ ORDER BY w.ordinal LIMIT 1`, [namespace, selection.claimAt, selection.claimAt]);
68
+ if (row) {
69
+ this.metrics.rowsRead++;
70
+ id = JSON.parse(row.body).conversationId;
71
+ }
72
+ }
73
+ let target = id ? await this.one(namespace, 'conversations', id) : undefined;
74
+ if (target) {
75
+ await this.db.lock(JSON.stringify([namespace, 'binding', target.bindingId]));
76
+ target = await this.one(namespace, 'conversations', target.id);
77
+ }
78
+ if (!target)
79
+ return state;
80
+ if (selection.cursorCheck !== undefined) {
81
+ const found = await this.sqlGet("SELECT seq FROM poppy_server.event_cursors WHERE ns=$1 AND conversation=$2 AND id=$3", [namespace, target.id, selection.cursorCheck]);
82
+ if (!found)
83
+ state.cursorStatus = 'invalid';
84
+ else if (!await this.sqlGet("SELECT 1 FROM poppy_server.events WHERE ns=$1 AND conversation=$2 AND seq=$3", [namespace, target.id, Number(found.seq)]))
85
+ state.cursorStatus = 'expired';
86
+ }
87
+ for (const row of await this.rows(namespace, 'conversations', selection.events ? 'AND id=$3' : "AND (body::jsonb->>'bindingId')=$3", [selection.events ? target.id : target.bindingId])) {
88
+ const conversation = JSON.parse(row.body);
89
+ let from = -1;
90
+ if (selection.events?.cursor !== undefined) {
91
+ const found = await this.sqlGet("SELECT seq FROM poppy_server.event_cursors WHERE ns=$1 AND conversation=$2 AND id=$3", [namespace, row.id, selection.events.cursor]);
92
+ if (!found)
93
+ conversation.eventWindow = { invalid: true, hasMore: false };
94
+ else {
95
+ from = Number(found.seq);
96
+ if (!await this.sqlGet("SELECT 1 FROM poppy_server.events WHERE ns=$1 AND conversation=$2 AND seq=$3", [namespace, row.id, from]))
97
+ conversation.eventWindow = { expired: true, hasMore: false };
98
+ }
99
+ }
100
+ const events = [];
101
+ let hasMore = false;
102
+ if (selection.events) {
103
+ let bytes = 0;
104
+ for (const event of await this.sqlAll("SELECT body FROM poppy_server.events WHERE ns=$1 AND conversation=$2 AND seq>$3 ORDER BY seq LIMIT $4", [namespace, row.id, from, selection.events.limit + 1])) {
105
+ this.metrics.rowsRead++;
106
+ const size = Buffer.byteLength(event.body) + 1;
107
+ if (events.length >= selection.events.limit || bytes + size > (selection.events.maxBytes ?? 1048576)) {
108
+ hasMore = true;
109
+ break;
110
+ }
111
+ events.push(event);
112
+ bytes += size;
113
+ }
114
+ conversation.eventWindow = { ...conversation.eventWindow, hasMore };
115
+ }
116
+ else if (!selection.append)
117
+ events.push(...await this.sqlAll("SELECT body FROM poppy_server.events WHERE ns=$1 AND conversation=$2 ORDER BY seq", [namespace, row.id]));
118
+ if (!selection.events)
119
+ this.metrics.rowsRead += events.length;
120
+ if (selection.append && !conversation.lastUserMessage) {
121
+ const last = await this.sqlGet("SELECT body FROM poppy_server.events WHERE ns=$1 AND conversation=$2 AND (body::jsonb#>>'{type}')='message' AND (body::jsonb#>>'{message,role}')='user' ORDER BY seq DESC LIMIT 1", [namespace, row.id]);
122
+ if (last) {
123
+ this.metrics.rowsRead++;
124
+ conversation.lastUserMessage = JSON.parse(last.body).message;
125
+ }
126
+ }
127
+ conversation.events = events.map(event => JSON.parse(event.body));
128
+ state.conversations[row.id] = conversation;
129
+ if (!selection.events)
130
+ state.work.push(...(await this.rows(namespace, 'work', "AND (body::jsonb#>>'{conversationId}')=$3 ORDER BY ordinal", [row.id])).map(work => JSON.parse(work.body)));
131
+ }
132
+ const binding = await this.one(namespace, 'bindings', target.bindingId);
133
+ if (binding)
134
+ state.bindings[target.bindingId] = binding;
135
+ return state;
136
+ };
137
+ return {
138
+ pruneEvents: (conversationId, before, limit = 100) => {
139
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000 || !Number.isFinite(before))
140
+ throw new TypeError('retention limit');
141
+ return this.run(async () => {
142
+ const target = await this.one(namespace, 'conversations', conversationId);
143
+ if (target)
144
+ await this.db.lock(JSON.stringify([namespace, 'binding', target.bindingId]));
145
+ const active = await this.sqlGet("SELECT 1 FROM poppy_server.records WHERE ns=$1 AND kind='work' AND (body::jsonb#>>'{conversationId}')=$2 AND (body::jsonb#>>'{phase}') IN ('held','pending','claimed') LIMIT 1", [namespace, conversationId]);
146
+ if (active)
147
+ return 0;
148
+ const removed = await this.sqlRun("DELETE FROM poppy_server.events WHERE ns=$1 AND conversation=$2 AND seq IN (SELECT seq FROM poppy_server.events WHERE ns=$3 AND conversation=$4 AND (body::jsonb#>>'{created_at}')<$5 ORDER BY seq LIMIT $6)", [namespace, conversationId, namespace, conversationId, new Date(before).toISOString(), limit]);
149
+ return Number(removed.changes);
150
+ });
151
+ },
152
+ read: selection => this.run(async () => await load(selection)),
153
+ transaction: (selection, change) => this.run(async () => this.atomic(async () => {
154
+ const state = await load(selection), before = structuredClone(state);
155
+ const result = change(state);
156
+ if (result && typeof result.then === 'function')
157
+ throw new TypeError('async conversation transaction');
158
+ if (selection.events) {
159
+ if (json(state) !== json(before))
160
+ throw new TypeError('event windows are read-only');
161
+ return structuredClone(result);
162
+ }
163
+ if (state.version !== before.version || state.generation !== before.generation || Object.keys(before.conversations).some(id => !Object.hasOwn(state.conversations, id)))
164
+ throw new TypeError('use an explicit offline reset');
165
+ for (const [id, conversation] of Object.entries(state.conversations)) {
166
+ const oldEvents = before.conversations[id]?.events ?? [];
167
+ if (conversation.events.length < oldEvents.length || oldEvents.some((event, i) => json(event) !== json(conversation.events[i])))
168
+ throw new TypeError('events must be append-only');
169
+ const last = Number((await this.sqlGet("SELECT coalesce(max(seq),-1) AS seq FROM poppy_server.event_cursors WHERE ns=$1 AND conversation=$2", [namespace, id])).seq);
170
+ for (let i = oldEvents.length; i < conversation.events.length; i++) {
171
+ const seq = last + i - oldEvents.length + 1, event = conversation.events[i];
172
+ await this.sqlRun("INSERT INTO poppy_server.events VALUES ($1,$2,$3,$4)", [namespace, id, seq, json(event)]);
173
+ await this.sqlRun("INSERT INTO poppy_server.event_cursors VALUES ($1,$2,$3,$4)", [namespace, id, event.id, seq]);
174
+ this.metrics.rowsWritten += 2;
175
+ }
176
+ const { events: _events, ...record } = conversation;
177
+ const { events: _oldEvents, ...old } = before.conversations[id] ?? {};
178
+ if (json(record) !== json(old))
179
+ await this.put(namespace, 'conversations', id, record);
180
+ }
181
+ await this.save(namespace, 'bindings', before.bindings, state.bindings);
182
+ await this.save(namespace, 'messages', before.messages, state.messages);
183
+ await this.save(namespace, 'work', Object.fromEntries(before.work.map(row => [row.id, row])), Object.fromEntries(state.work.map(row => [row.id, row])));
184
+ return structuredClone(result);
185
+ })),
186
+ };
187
+ }
188
+ issuer(namespace) {
189
+ const kinds = ['sessions', 'grants', 'refreshTokens', 'authorizations', 'codes', 'signIns', 'accessTokens', 'replay'];
190
+ const load = async (selection) => {
191
+ await this.db.lock(JSON.stringify([namespace, 'issuer']));
192
+ const state = { ...await this.meta(namespace, 'issuer_meta', () => { const { version, generation, nonces } = initialIssuerState(); return { version, generation, nonces }; }), sessions: {}, grants: {}, refreshTokens: {}, authorizations: {}, codes: {}, signIns: {}, accessTokens: {}, replay: {} };
193
+ const get = async (kind, id) => {
194
+ if (!id)
195
+ return;
196
+ const value = await this.one(namespace, kind, id);
197
+ if (value !== undefined)
198
+ state[kind][id] = value;
199
+ };
200
+ const find = async (kind, where, ...params) => {
201
+ for (const row of await this.rows(namespace, kind, where, params))
202
+ state[kind][row.id] = JSON.parse(row.body);
203
+ };
204
+ if (selection.kind === 'access')
205
+ await get('accessTokens', selection.hash);
206
+ if (selection.kind === 'sign_in') {
207
+ for (const id of selection.ids)
208
+ await get('signIns', id);
209
+ await get('sessions', selection.sessionId);
210
+ await get('accessTokens', selection.accessHash);
211
+ for (const key of selection.replayKeys)
212
+ await get('replay', key);
213
+ const counts = (await this.sqlGet("SELECT count(*) AS n, sum(CASE WHEN (body::jsonb#>>'{clientId}')=$1 THEN 1 ELSE 0 END) AS client FROM poppy_server.records WHERE ns=$2 AND kind='signIns' AND (body::jsonb#>>'{status}') IN ('pending','approved','verifying') AND ((body::jsonb#>>'{expiresAt}')::double precision)>$3", [selection.clientId ?? '', namespace, selection.at]));
214
+ state.limits = { sessions: 0, earliestSessionExpiry: 0, pending: Number(counts.n), pendingForClient: Number(counts.client ?? 0) };
215
+ }
216
+ if (selection.kind === 'principal') {
217
+ await get('sessions', selection.sessionId);
218
+ await get('grants', selection.grantId);
219
+ }
220
+ if (selection.kind === 'browser') {
221
+ await get('sessions', selection.sessionId);
222
+ await get('replay', selection.replayKey);
223
+ }
224
+ if (selection.kind === 'authorization') {
225
+ await get('authorizations', selection.id);
226
+ await get('signIns', selection.revocationKey);
227
+ }
228
+ if (selection.kind === 'token') {
229
+ await get('sessions', selection.sessionId);
230
+ await get('codes', selection.codeHash);
231
+ await get('refreshTokens', selection.refreshHash);
232
+ for (const key of selection.replayKeys)
233
+ await get('replay', key);
234
+ const counts = (await this.sqlGet("SELECT count(*) AS n, min(((body::jsonb#>>'{expiresAt}')::double precision)) AS expiry FROM poppy_server.records WHERE ns=$1 AND kind='sessions' AND (body::jsonb#>>'{clientId}')=$2 AND ((body::jsonb#>>'{expiresAt}')::double precision)>$3", [namespace, selection.clientId, selection.at]));
235
+ state.limits = { sessions: Number(counts.n), earliestSessionExpiry: Number(counts.expiry ?? selection.at), pending: 0, pendingForClient: 0 };
236
+ }
237
+ if (selection.kind === 'revoke') {
238
+ await get('refreshTokens', selection.refreshHash);
239
+ await get('replay', selection.replayKey);
240
+ }
241
+ if (selection.kind === 'authorize') {
242
+ await get('signIns', clientRevisionKey(selection.clientId));
243
+ const pending = (await this.sqlGet("SELECT count(*) AS n FROM poppy_server.records WHERE ns=$1 AND kind='authorizations' AND body::jsonb->'completion' IS NULL AND ((body::jsonb#>>'{expiresAt}')::double precision)>$2", [namespace, selection.at]));
244
+ const client = (await this.sqlGet("SELECT count(*) AS n FROM poppy_server.records WHERE ns=$1 AND kind='authorizations' AND body::jsonb->'completion' IS NULL AND (body::jsonb#>>'{clientId}')=$2 AND ((body::jsonb#>>'{expiresAt}')::double precision)>$3", [namespace, selection.clientId, selection.at]));
245
+ state.limits = { sessions: 0, earliestSessionExpiry: 0, pending: Number(pending.n), pendingForClient: Number(client.n) };
246
+ }
247
+ if (selection.kind === 'disconnect') {
248
+ for (const kind of ['grants', 'codes'])
249
+ await find(kind, "AND (body::jsonb->>'clientId')=$3 AND (body::jsonb->>'accountId')=$4", selection.clientId, selection.accountId);
250
+ await find('signIns', "AND (body::jsonb->>'clientId')=$3 AND (body::jsonb->>'accountId')=$4", selection.clientId, selection.accountId);
251
+ await get('signIns', clientRevisionKey(selection.clientId));
252
+ await get('signIns', accountRevisionKey(selection.clientId, selection.accountId));
253
+ }
254
+ for (const record of Object.values(state.accessTokens)) {
255
+ await get('sessions', record.sessionId);
256
+ if (record.grantId)
257
+ await get('grants', record.grantId);
258
+ }
259
+ for (const record of [...Object.values(state.sessions), ...Object.values(state.codes)])
260
+ if (record.grantId)
261
+ await get('grants', record.grantId);
262
+ for (const record of Object.values(state.codes))
263
+ await get('signIns', accountRevisionKey(record.clientId, record.accountId));
264
+ for (const id of Object.values(state.refreshTokens))
265
+ await get('grants', id);
266
+ for (const [id, grant] of Object.entries(state.grants)) {
267
+ await get('refreshTokens', grant.refreshHash);
268
+ await get('refreshTokens', grant.previousHash);
269
+ if (['token', 'revoke', 'disconnect'].includes(selection.kind))
270
+ await find('sessions', "AND (body::jsonb->>'grantId')=$3", id);
271
+ }
272
+ return state;
273
+ };
274
+ return {
275
+ read: selection => this.run(async () => await load(selection)),
276
+ transaction: (selection, change) => this.run(async () => this.atomic(async () => {
277
+ const state = await load(selection), before = structuredClone(state);
278
+ const result = change(state);
279
+ if (result && typeof result.then === 'function')
280
+ throw new TypeError('async issuer transaction');
281
+ for (const kind of kinds)
282
+ await this.save(namespace, kind, before[kind], state[kind]);
283
+ const { version, generation, nonces } = state;
284
+ if (json({ version, generation, nonces }) !== json({ version: before.version, generation: before.generation, nonces: before.nonces }))
285
+ await this.put(namespace, 'issuer_meta', 'meta', { version, generation, nonces });
286
+ return structuredClone(result);
287
+ })),
288
+ };
289
+ }
290
+ /** Bounded maintenance; never deletes message receipts, events, grants or work intent. */
291
+ cleanup(namespace, now, limit = 100) {
292
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
293
+ throw new TypeError('cleanup limit');
294
+ return this.run(async () => {
295
+ await this.db.lock(JSON.stringify([namespace, 'issuer']));
296
+ const rows = await this.sqlAll(`SELECT kind,id FROM poppy_server.records AS retained WHERE ns=$1 AND
297
+ ((kind IN ('authorizations','codes','accessTokens') AND ((body::jsonb#>>'{expiresAt}')::double precision)<=$2) OR (kind='signIns' AND ((body::jsonb#>>'{expiresAt}')::double precision)+30000<$3) OR (kind='replay' AND (body::jsonb#>>'{}')::double precision<$4)
298
+ OR (kind='refreshTokens' AND NOT EXISTS (SELECT 1 FROM poppy_server.records AS lineage WHERE lineage.ns=retained.ns AND lineage.kind='grants' AND lineage.id=retained.body::jsonb#>>'{}' AND (lineage.body::jsonb->>'expiresAt')::double precision>$5 AND lineage.body::jsonb->>'revoked'='false'))) LIMIT $6`, [namespace, now, now, now, now, limit]);
299
+ for (const row of rows)
300
+ await this.sqlRun("DELETE FROM poppy_server.records WHERE ns=$1 AND kind=$2 AND id=$3", [namespace, row.kind, row.id]);
301
+ return rows.length;
302
+ });
303
+ }
304
+ }
@@ -41,6 +41,10 @@ interface AuthorizationRecord {
41
41
  browserHash: string;
42
42
  expiresAt: number;
43
43
  revision: number;
44
+ completion?: {
45
+ decisionHash: string;
46
+ callback: string;
47
+ };
44
48
  }
45
49
  interface CodeRecord {
46
50
  clientId: string;
@@ -156,6 +160,11 @@ export interface ReferenceIssuerOptions {
156
160
  dpop: boolean;
157
161
  }[];
158
162
  metadata: MetadataResolver;
163
+ /** Required for Direct Sign-In: synchronous authenticated encryption with a durable host-owned key. */
164
+ authorizationResultCipher?: {
165
+ seal(value: string): string;
166
+ open(value: string): string;
167
+ };
159
168
  store: IssuerStore;
160
169
  coordinator: AuthorityCoordinator;
161
170
  now: () => number;
@@ -661,8 +661,7 @@ export function createReferenceIssuer(options) {
661
661
  if (!scopes.every(item => lineage.scopes.includes(item)))
662
662
  throw new TokenError('invalid_scope');
663
663
  const successor = random(32);
664
- // Older hashes are forgotten: the previous one is enough to revoke after a lost rotation response.
665
- delete draft.refreshTokens[lineage.previousHash];
664
+ // Retain ancestors until the fixed grant expiry so every replay can revoke its descendants.
666
665
  lineage.previousHash = lineage.refreshHash;
667
666
  lineage.refreshHash = tokenHash(successor);
668
667
  draft.refreshTokens[lineage.refreshHash] = grantId;
@@ -702,9 +701,9 @@ export function createReferenceIssuer(options) {
702
701
  const presented = tokenHash(refreshToken);
703
702
  const grantId = own(draft.refreshTokens, presented);
704
703
  const lineage = grantId === undefined ? undefined : own(draft.grants, grantId);
705
- // Reuse of the previous Account Token (e.g. a retry after a lost response) revokes the lineage, so its Sessions
704
+ // Reuse of any ancestor Account Token (e.g. a retry after a lost response) revokes the lineage, so its Sessions
706
705
  // continue signed out instead of staying bound to a successor nobody holds (RFC 9700 4.14.2).
707
- if (lineage && lineage.clientId === clientId && presented === lineage.previousHash) {
706
+ if (lineage && lineage.clientId === clientId && presented !== lineage.refreshHash) {
708
707
  revokeGrant(draft, grantId);
709
708
  return null;
710
709
  }
@@ -777,7 +776,7 @@ export function createReferenceIssuer(options) {
777
776
  const state = await read({ kind: 'principal', sessionId: principal.sessionId, ...(principal.grantId ? { grantId: principal.grantId } : {}) });
778
777
  const session = own(state.sessions, principal.sessionId);
779
778
  if (principal.issuer !== issuer || principal.issuerGeneration !== state.generation || !session || session.clientId !== principal.clientId
780
- || session.expiresAt <= now() || session.userId !== principal.userId || !liveGrant(state, principal))
779
+ || principal.tokenExpiresAt <= now() || session.expiresAt <= now() || session.userId !== principal.userId || !liveGrant(state, principal))
781
780
  throw new AuthorityError('invalid_token');
782
781
  requireScopes(principal.scopes, scopes);
783
782
  return action();
@@ -858,6 +857,8 @@ export function createReferenceIssuer(options) {
858
857
  // Validates the whole request before persisting anything; the host renders login and consent from the view.
859
858
  // Unauthenticated: hosts must rate-limit their /authorize route; the pending cap only bounds state and coordinator time.
860
859
  async beginAuthorization(request, browserBinding) {
860
+ if (!options.authorizationResultCipher)
861
+ throw new TypeError('authorizationResultCipher is required for Direct Sign-In');
861
862
  if (typeof browserBinding !== 'string' || !browserBinding)
862
863
  throw new TypeError('browserBinding');
863
864
  const params = uniqueParams(new URL(request.url).searchParams, () => new AuthorizationError('invalid_request'));
@@ -905,7 +906,7 @@ export function createReferenceIssuer(options) {
905
906
  const at = now();
906
907
  prune(draft, at);
907
908
  // shortcut: an unauthenticated caller can fill one client's slots for AUTHORIZATION_MS (10 minutes); hosts must rate-limit /authorize.
908
- const pending = Object.values(draft.authorizations);
909
+ const pending = Object.values(draft.authorizations).filter(record => !record.completion);
909
910
  if ((draft.limits?.pending ?? pending.length) >= maxPending || (draft.limits?.pendingForClient ?? pending.filter(record => record.clientId === clientId).length) >= maxPendingPerClient)
910
911
  throw fail('temporarily_unavailable');
911
912
  const id = random(16);
@@ -914,8 +915,11 @@ export function createReferenceIssuer(options) {
914
915
  return { id, clientId, clientName: client.client_name, scopes, expiresAt };
915
916
  });
916
917
  },
917
- // Consumes the authorization once; the browser binding is checked before deny as well as approve.
918
+ // Commit once; a bound identical decision can recover the encrypted callback without minting another code.
918
919
  async completeAuthorization(id, browserBinding, decision) {
920
+ const cipher = options.authorizationResultCipher;
921
+ if (!cipher)
922
+ throw new TypeError('authorizationResultCipher is required for Direct Sign-In');
919
923
  if (typeof id !== 'string' || typeof browserBinding !== 'string')
920
924
  throw new AuthorizationError('invalid_request');
921
925
  const snapshot = own((await read({ kind: 'authorization', id })).authorizations, id);
@@ -924,21 +928,32 @@ export function createReferenceIssuer(options) {
924
928
  const record = own(draft.authorizations, id);
925
929
  if (!record || record.expiresAt <= now() || record.browserHash !== tokenHash(browserBinding))
926
930
  throw new AuthorizationError('invalid_request');
927
- const fields = { state: record.state, iss: issuer };
928
- if ('denied' in decision && decision.denied === true) {
929
- delete draft.authorizations[id];
930
- return callback(record.redirectUri, { error: 'access_denied', ...fields });
931
- }
931
+ const denied = 'denied' in decision && decision.denied === true;
932
932
  const { accountId, scopes } = decision;
933
933
  // Partial consent narrows; the host may never grant more than was requested.
934
- if (typeof accountId !== 'string' || !accountId || !Array.isArray(scopes) || !scopes.length || !scopes.every(scope => record.scopes.includes(scope)))
934
+ if (!denied && (typeof accountId !== 'string' || !accountId || !Array.isArray(scopes) || !scopes.length || !scopes.every(scope => record.scopes.includes(scope))))
935
935
  throw new TypeError('decision');
936
- if (!consentCurrent(draft, record.clientId, accountId, record.revision))
936
+ if (!denied && !consentCurrent(draft, record.clientId, accountId, record.revision))
937
937
  throw new AuthorizationError('invalid_request');
938
- delete draft.authorizations[id];
938
+ const granted = denied ? [] : [...new Set(scopes)].sort();
939
+ const decisionHash = tokenHash(JSON.stringify(denied ? { denied: true } : { accountId, scopes: granted }));
940
+ if (record.completion) {
941
+ if (record.completion.decisionHash !== decisionHash)
942
+ throw new AuthorizationError('invalid_request');
943
+ return cipher.open(record.completion.callback);
944
+ }
945
+ const fields = { state: record.state, iss: issuer };
946
+ if (denied) {
947
+ const href = callback(record.redirectUri, { error: 'access_denied', ...fields });
948
+ record.completion = { decisionHash, callback: cipher.seal(href) };
949
+ return href;
950
+ }
939
951
  const code = random(32);
940
- draft.codes[tokenHash(code)] = { clientId: record.clientId, redirectUri: record.redirectUri, codeChallenge: record.codeChallenge, accountId, scopes: [...new Set(scopes)], expiresAt: now() + CODE_MS, grantId: null, revision: record.revision };
941
- return callback(record.redirectUri, { code, ...fields });
952
+ record.expiresAt = Math.min(record.expiresAt, now() + CODE_MS);
953
+ draft.codes[tokenHash(code)] = { clientId: record.clientId, redirectUri: record.redirectUri, codeChallenge: record.codeChallenge, accountId: accountId, scopes: granted, expiresAt: record.expiresAt, grantId: null, revision: record.revision };
954
+ const href = callback(record.redirectUri, { code, ...fields });
955
+ record.completion = { decisionHash, callback: cipher.seal(href) };
956
+ return href;
942
957
  });
943
958
  return new URL(href);
944
959
  },
package/dist/sqlite.js CHANGED
@@ -307,8 +307,8 @@ export class SqliteCompanyDatabase {
307
307
  }
308
308
  if (selection.kind === 'authorize') {
309
309
  get('signIns', clientRevisionKey(selection.clientId));
310
- const pending = this.db.prepare("SELECT count(*) AS n FROM records WHERE ns=? AND kind='authorizations' AND json_extract(body,'$.expiresAt')>?").get(namespace, selection.at);
311
- const client = this.db.prepare("SELECT count(*) AS n FROM records WHERE ns=? AND kind='authorizations' AND json_extract(body,'$.clientId')=? AND json_extract(body,'$.expiresAt')>?").get(namespace, selection.clientId, selection.at);
310
+ const pending = this.db.prepare("SELECT count(*) AS n FROM records WHERE ns=? AND kind='authorizations' AND json_extract(body,'$.completion') IS NULL AND json_extract(body,'$.expiresAt')>?").get(namespace, selection.at);
311
+ const client = this.db.prepare("SELECT count(*) AS n FROM records WHERE ns=? AND kind='authorizations' AND json_extract(body,'$.completion') IS NULL AND json_extract(body,'$.clientId')=? AND json_extract(body,'$.expiresAt')>?").get(namespace, selection.clientId, selection.at);
312
312
  state.limits = { sessions: 0, earliestSessionExpiry: 0, pending: Number(pending.n), pendingForClient: Number(client.n) };
313
313
  }
314
314
  if (selection.kind === 'disconnect') {
@@ -359,8 +359,9 @@ export class SqliteCompanyDatabase {
359
359
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
360
360
  throw new TypeError('cleanup limit');
361
361
  return this.run(async () => {
362
- const rows = this.db.prepare(`SELECT kind,id FROM records WHERE ns=? AND
363
- ((kind IN ('authorizations','codes','accessTokens') AND json_extract(body,'$.expiresAt')<=?) OR (kind='signIns' AND json_extract(body,'$.expiresAt')+30000<?) OR (kind='replay' AND CAST(body AS REAL)<?)) LIMIT ?`).all(namespace, now, now, now, limit);
362
+ const rows = this.db.prepare(`SELECT kind,id FROM records AS retained WHERE ns=? AND
363
+ ((kind IN ('authorizations','codes','accessTokens') AND json_extract(body,'$.expiresAt')<=?) OR (kind='signIns' AND json_extract(body,'$.expiresAt')+30000<?) OR (kind='replay' AND CAST(body AS REAL)<?)
364
+ OR (kind='refreshTokens' AND NOT EXISTS (SELECT 1 FROM records AS lineage WHERE lineage.ns=retained.ns AND lineage.kind='grants' AND lineage.id=json_extract(retained.body,'$') AND json_extract(lineage.body,'$.expiresAt')>? AND json_extract(lineage.body,'$.revoked')=0))) LIMIT ?`).all(namespace, now, now, now, now, limit);
364
365
  for (const row of rows)
365
366
  this.db.prepare('DELETE FROM records WHERE ns=? AND kind=? AND id=?').run(namespace, row.kind, row.id);
366
367
  return rows.length;
@@ -0,0 +1,16 @@
1
+ CREATE SCHEMA poppy_server;
2
+ CREATE TABLE poppy_server.schema_version (version integer PRIMARY KEY CHECK(version > 0));
3
+ INSERT INTO poppy_server.schema_version VALUES (1);
4
+ CREATE TABLE poppy_server.records (ns text NOT NULL, kind text NOT NULL, id text NOT NULL, body text NOT NULL CHECK (body::jsonb IS NOT NULL), ordinal bigint GENERATED ALWAYS AS IDENTITY, PRIMARY KEY(ns,kind,id));
5
+ CREATE INDEX by_conversation ON poppy_server.records (ns,kind,(body::jsonb#>>'{conversationId}'));
6
+ CREATE INDEX by_binding ON poppy_server.records (ns,kind,(body::jsonb#>>'{bindingId}'));
7
+ CREATE INDEX by_client ON poppy_server.records (ns,kind,(body::jsonb#>>'{clientId}'));
8
+ CREATE INDEX by_account ON poppy_server.records (ns,kind,(body::jsonb#>>'{accountId}'));
9
+ CREATE INDEX by_grant ON poppy_server.records (ns,kind,(body::jsonb#>>'{grantId}'));
10
+ CREATE INDEX by_phase ON poppy_server.records (ns,kind,(body::jsonb#>>'{phase}'));
11
+ CREATE INDEX by_expiry ON poppy_server.records (ns,kind,((body::jsonb#>>'{expiresAt}')::double precision));
12
+ CREATE INDEX by_connection ON poppy_server.records (ns,kind,(body::jsonb#>>'{connectionId}'));
13
+ CREATE INDEX by_callback ON poppy_server.records (ns,kind,(body::jsonb#>>'{direct,stateHash}'));
14
+ CREATE TABLE poppy_server.events (ns text NOT NULL, conversation text NOT NULL, seq bigint NOT NULL, body text NOT NULL, PRIMARY KEY(ns,conversation,seq));
15
+ CREATE INDEX last_user_event ON poppy_server.events(ns,conversation,((body::jsonb)->>'type'),((body::jsonb)#>>'{message,role}'),seq);
16
+ CREATE TABLE poppy_server.event_cursors (ns text NOT NULL, conversation text NOT NULL, id text NOT NULL, seq bigint NOT NULL, PRIMARY KEY(ns,conversation,id), UNIQUE(ns,conversation,seq));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poppykit/server",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "Personal Agent Protocol company 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.22.1 <25"
13
13
  },
14
14
  "packageManager": "pnpm@11.25.0",
15
15
  "scripts": {
@@ -24,7 +24,9 @@
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/node": "24.10.0",
27
- "typescript": "5.9.3"
27
+ "typescript": "5.9.3",
28
+ "pg": "8.23.1",
29
+ "@types/pg": "8.23.1"
28
30
  },
29
31
  "files": [
30
32
  "dist",
@@ -39,7 +41,8 @@
39
41
  "CHANGELOG.md",
40
42
  "RELEASING.md",
41
43
  "examples",
42
- "LICENSE"
44
+ "LICENSE",
45
+ "migrations"
43
46
  ],
44
47
  "exports": {
45
48
  ".": {
@@ -57,6 +60,10 @@
57
60
  "./sqlite": {
58
61
  "types": "./dist/sqlite.d.ts",
59
62
  "import": "./dist/sqlite.js"
63
+ },
64
+ "./postgres": {
65
+ "types": "./dist/postgres.d.ts",
66
+ "import": "./dist/postgres.js"
60
67
  }
61
68
  },
62
69
  "dependencies": {
@@ -65,5 +72,13 @@
65
72
  "license": "MIT",
66
73
  "publishConfig": {
67
74
  "access": "public"
75
+ },
76
+ "peerDependencies": {
77
+ "pg": "^8.23.1"
78
+ },
79
+ "peerDependenciesMeta": {
80
+ "pg": {
81
+ "optional": true
82
+ }
68
83
  }
69
84
  }