@poppykit/client 0.1.1 → 0.2.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
@@ -23,3 +23,7 @@ and endpoint; never substitute profile names or model-provided URLs for that bin
23
23
  The [quickstart](examples/quickstart.mjs) is executed by CI. Integration examples
24
24
  describe adapters your application supplies; they are not built-in browser drivers
25
25
  or general API executors.
26
+
27
+ The optional Postgres entry point ships its adapters, `createPostgresPool`,
28
+ `postgresMigration`, `migratePostgres` and schema-version export; see [storage](STORAGE.md).
29
+ TypeScript Postgres consumers also install `@types/pg@8.23.1` as a development dependency.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 (release candidate)
4
+
5
+ Add bundled Postgres auth/conversation/provider stores and explicit migrations. Provider APIs and targeted storage transactions are asynchronous; await callback/result/revocation operations. SQLite and explicit legacy local-store migration remain supported.
6
+
3
7
  ## 0.1.1
4
8
 
5
9
  Documentation refresh: registry installation, runnable quickstarts, focused guides
package/PROVIDER.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Import `createProvider`, `createProviderClient`, and `createResultCipher` from
4
4
  `@poppykit/client/provider`; durable `SqliteProviderStore` is separately exported
5
5
  from `@poppykit/client/provider/sqlite`. Neither is loaded by the core import.
6
- This is PoppyKit provider contract **v1**, not a Personal Agent Protocol extension.
6
+ The wire protocol remains PoppyKit provider **v1**; the 0.2 TypeScript API uses async storage, not a Personal Agent Protocol extension.
7
7
  Node 24.12+ is required for the SQLite reference adapter.
8
8
 
9
9
  An operator supplies HTTPS hosting, a provider client metadata URL, JWKS URL,
@@ -65,7 +65,7 @@ issuer, and exactly one code/error; result ciphertext is sealed atomically with
65
65
  the immutable binding. Identical pending callbacks are harmless; conflicting,
66
66
  canceled, expired and consumed callbacks fail. `takeResult` requires enrollment,
67
67
  installation, operation, issuer and exact session; it consumes within the same
68
- SQLite transaction before returning private callback data. Lost retrieval delivery
68
+ storage transaction before returning private callback data. Lost retrieval delivery
69
69
  is uncertain, never a reason to exchange or retrieve again. SDK code owns PKCE and
70
70
  the exchange. Never log request URLs or responses on this route.
71
71
 
@@ -97,3 +97,26 @@ Account Tokens or DPoP keys. Revoke company authorization **before** disabling
97
97
  provider signing, or use authenticated company account controls afterward.
98
98
  This is scoped authority, redaction and provider key custody; it does not isolate
99
99
  authority from unrestricted programs running as the same OS user.
100
+
101
+ ## Migrating provider code to 0.2
102
+
103
+ Await every provider operation, including `callback`, `takeResult`, `cancel` and
104
+ `revokeEnrollment`; HTTP callback success is returned only after storage commits.
105
+ Use rejected-Promise assertions for failures. Signer, enrollment authentication,
106
+ metadata and session verification stay outside storage transactions; ResultCipher
107
+ remains synchronous local cryptography.
108
+
109
+ `ProviderSelection` declares enroll, identity, operation, begin, callback or revoke.
110
+ Remote stores load and lock only that aggregate; out-of-selection access and thenable
111
+ callbacks reject. Enroll locks a namespace capacity counter; identity mapping locks
112
+ visitor/full-issuer keys across installations; callbacks re-resolve state after the
113
+ enrollment fence. Begin/revoke inspect at most 1,001 operation rows. All valid Unicode
114
+ operation IDs count toward limits and revocation. Existing SQLite databases retain
115
+ records and initialize the capacity counter from their enrollment rows.
116
+
117
+ For an existing local synchronous custom store, explicitly wrap it with
118
+ `adaptLegacyProviderStore(legacyStore)`. This migration helper ignores selections
119
+ and cannot turn local synchronous I/O into remote persistence. New remote/custom
120
+ stores implement the targeted contract directly. The bundled Postgres adapter is
121
+ `PostgresProviderStore` from `@poppykit/client/provider/postgres`; see
122
+ [storage](STORAGE.md) for explicit migrations, TLS and lifecycle.
package/README.md CHANGED
@@ -6,11 +6,13 @@ application supplies the agent and trusted infrastructure. [MIT](LICENSE).
6
6
 
7
7
  ## Install
8
8
 
9
+ Version 0.2.0 is a release candidate until its publishing workflow completes.
10
+
9
11
  Requires Node **24.12.0+**, ESM and HTTPS. CI tests Node 24.12.0 on Linux, macOS and
10
12
  Windows; later majors are not separately certified.
11
13
 
12
14
  ```sh
13
- npm install @poppykit/client@0.1.1
15
+ npm install @poppykit/client@0.2.0
14
16
  ```
15
17
 
16
18
  ## Discover a company
package/STORAGE.md CHANGED
@@ -94,3 +94,63 @@ checkpoints are retained until explicit local deletion; pending/uncertain retrie
94
94
  must first be reconciled. Checkpoint acknowledgements use compare-and-set and do
95
95
  not acknowledge merely because a page or stream frame was read. Local deletion
96
96
  never deletes company history, revokes access or cancels accepted business work.
97
+
98
+ ## Postgres (0.2 API)
99
+
100
+ The optional `@poppykit/client/postgres` exports `PostgresAuthStore` and
101
+ `PostgresConversationStore`; `/provider/postgres` exports `PostgresProviderStore`.
102
+ 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+
104
+ are supported; plain PostgreSQL and Supabase transaction pooling use the same adapter.
105
+ There is no Supabase Auth or application dependency.
106
+
107
+ ```ts
108
+ import { createPostgresPool, migratePostgres, PostgresAuthStore,
109
+ PostgresConversationStore } from '@poppykit/client/postgres';
110
+ import { PostgresProviderStore } from '@poppykit/client/provider/postgres';
111
+ const pool = createPostgresPool({ connectionString: process.env.DATABASE_URL });
112
+ // Explicit deployment administration, once before accepting traffic:
113
+ await migratePostgres(pool, 'poppy_client');
114
+ await migratePostgres(pool, 'poppy_provider');
115
+ const auth = new PostgresAuthStore(pool, 'installation:user');
116
+ const conversations = new PostgresConversationStore(pool, 'installation:user');
117
+ const provider = new PostgresProviderStore(pool, 'provider-instance');
118
+ // After requests finish:
119
+ await auth.close(); await conversations.close(); await provider.close();
120
+ await pool.end();
121
+ ```
122
+
123
+ A supplied pool belongs to the caller: adapter `close` stops that adapter and
124
+ never ends the pool. `createPostgresPool` returns a pool owned by its caller.
125
+ Defaults: two connections, 3s acquisition, 10s idle, verified TLS. Supply a CA via
126
+ `ssl: { ca, rejectUnauthorized: true }` for a private root. TLS URL overrides and
127
+ `rejectUnauthorized: false` are rejected; `ssl: false` is an explicit trusted-local
128
+ server choice. Caller-created pools are the caller's responsibility. Queries are
129
+ unnamed/parameterized, and each transaction uses one checked-out connection.
130
+ Transaction/statement limits are 5s and lock waits 2s. No session locks, named
131
+ prepared statements, LISTEN or session state are required by transaction pooling.
132
+ Do not perform HTTP, model or KMS calls in storage callbacks.
133
+
134
+ Versioned SQL ships at `migrations/postgres/0001_poppy_client.sql` and
135
+ `0001_poppy_provider.sql`. `postgresMigration(schema)` returns its SQL for review;
136
+ `migratePostgres` explicitly applies it atomically or accepts existing version 1.
137
+ Construction/request paths never create or upgrade schemas. Unknown/missing
138
+ versions fail closed. Version checks do not certify arbitrary operator edits to
139
+ table definitions: inspect deployment migration history and stop on schema drift.
140
+ Applied migration files are immutable; corrections require a new migration/version.
141
+ Keep SQL and the deployment's applied-migration ledger under version control.
142
+ Back up and rehearse before applying to durable data. Roll back application code
143
+ only to a compatible schema/API; restore a reviewed backup for destructive reversal.
144
+ No automatic SQLite import, live reset, schema drop or downgrade is supplied.
145
+
146
+ Auth uses its namespace owner/epoch fence and indexed connection/operation selectors.
147
+ Provider uses indexed enrollment/state/mapping selectors, an atomic capacity counter,
148
+ and bounded per-enrollment operations. These are individual row records, not a
149
+ whole-database snapshot. `cleanup` preserves pending/uncertain auth operations.
150
+ Namespaces are constructor configuration, never request-supplied authority.
151
+
152
+ Custom backends can run `runClientConversationAdapterContract` and
153
+ `runProviderAdapterContract` from `@poppykit/client/adapter-contract` with two
154
+ handles to a fresh shared namespace. Real process death and engine atomicity still
155
+ need backend-specific checks. Database credentials and encryption keys stay outside
156
+ records, prompts and logs; provider callback URLs remain encrypted by ResultCipher.
@@ -0,0 +1,15 @@
1
+ import type { ConversationStore } from './conversation-store.js';
2
+ import type { ProviderStore } from './provider.js';
3
+ export type ClientConversationAdapterFixture = () => Promise<{
4
+ store: ConversationStore;
5
+ second: ConversationStore;
6
+ close(): Promise<void>;
7
+ }>;
8
+ export type ProviderAdapterFixture = () => Promise<{
9
+ store: ProviderStore;
10
+ second: ProviderStore;
11
+ close(): Promise<void>;
12
+ }>;
13
+ /** Run against two handles sharing a fresh namespace; real engine/process tests remain the host's responsibility. */
14
+ export declare function runClientConversationAdapterContract(fixture: ClientConversationAdapterFixture): void;
15
+ export declare function runProviderAdapterContract(fixture: ProviderAdapterFixture): void;
@@ -0,0 +1,48 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ /** Run against two handles sharing a fresh namespace; real engine/process tests remain the host's responsibility. */
4
+ export function runClientConversationAdapterContract(fixture) {
5
+ test('client adapter contract: atomic receipt preparation, terminal settlement and checkpoint compare-and-swap', async () => {
6
+ const f = await fixture();
7
+ try {
8
+ const key = { connectionId: 'contract', issuer: 'https://issuer.example/one', endpoint: 'https://company.example/conversations' };
9
+ const row = { ...key, id: 'message', fingerprint: 'original', phase: 'pending', target: null, request: { message: { id: 'message', sender: 'agent', text: 'hello' } } };
10
+ const saved = await Promise.all([f.store.prepareOutbound(key, row), f.second.prepareOutbound(key, { ...row, fingerprint: 'changed' })]);
11
+ assert.equal(saved[0].fingerprint, saved[1].fingerprint);
12
+ saved[0].request.message.text = 'mutated';
13
+ assert.equal((await f.second.getOutbound(key, row.id))?.request.message.text, 'hello');
14
+ await f.store.settleOutbound(key, row.id, { phase: 'accepted', response: { conversation_id: 'conversation', status: 'idle', responder: 'agent' } });
15
+ await f.second.settleOutbound(key, row.id, { phase: 'rejected', error: { code: 'invalid_request', status: 400 } });
16
+ assert.equal((await f.store.getOutbound(key, row.id))?.phase, 'accepted');
17
+ await assert.rejects(f.store.settleOutbound({ ...key, endpoint: 'https://other.example/conversations' }, row.id, { phase: 'accepted' }));
18
+ assert.equal(await f.store.getOutbound({ ...key, issuer: 'https://issuer.example/two' }, row.id), undefined);
19
+ const checkpoint = { connectionId: key.connectionId, issuer: key.issuer, conversationId: 'conversation', name: 'consumer', cursor: 'one' };
20
+ await Promise.all([f.store.advanceCheckpoint(key, checkpoint, [undefined]), f.second.advanceCheckpoint(key, { ...checkpoint, cursor: 'two' }, [undefined])]);
21
+ const current = await f.store.getCheckpoint(key, 'conversation', 'consumer');
22
+ assert.ok(current === 'one' || current === 'two');
23
+ await f.second.advanceCheckpoint(key, { ...checkpoint, cursor: 'stale' }, [undefined]);
24
+ assert.equal(await f.store.getCheckpoint(key, 'conversation', 'consumer'), current);
25
+ }
26
+ finally {
27
+ await f.close();
28
+ }
29
+ });
30
+ }
31
+ export function runProviderAdapterContract(fixture) {
32
+ test('provider adapter contract: capacity races, detached results, rollback and selection boundaries', async () => {
33
+ const f = await fixture(), selection = { kind: 'enroll', subjectHash: 'subject', requestHash: 'request' };
34
+ try {
35
+ await Promise.all(Array.from({ length: 10 }, (_, i) => (i % 2 ? f.store : f.second).transaction(selection, tx => { tx.put('capacity', 'enrollments', { count: (tx.get('capacity', 'enrollments')?.count ?? 0) + 1 }); })));
36
+ const result = await f.store.transaction(selection, tx => tx.get('capacity', 'enrollments'));
37
+ assert.equal(result?.count, 10);
38
+ result.count = 999;
39
+ await assert.rejects(f.store.transaction(selection, tx => { tx.put('capacity', 'enrollments', { count: 0 }); throw new Error('rollback'); }), /rollback/);
40
+ await assert.rejects(f.store.transaction(selection, async (tx) => { tx.put('capacity', 'enrollments', { count: 0 }); }), /synchronous/);
41
+ await assert.rejects(f.store.transaction(selection, tx => tx.get('enrollment', 'foreign')), /outside selection/);
42
+ assert.equal((await f.second.transaction(selection, tx => tx.get('capacity', 'enrollments')))?.count, 10);
43
+ }
44
+ finally {
45
+ await f.close();
46
+ }
47
+ });
48
+ }
@@ -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,39 @@
1
+ import type { Pool } from 'pg';
2
+ import type { ClientAuthState } from './auth/client-auth.js';
3
+ import type { AuthSelection, AuthStore } from './auth/store.js';
4
+ import type { ConversationKey, ConversationStore } from './conversation-store.js';
5
+ import type { ConsumerCheckpoint, OutboundRecord } from './conversation.js';
6
+ export { createPostgresPool, migratePostgres, postgresMigration, postgresSchemaVersion } from './postgres-runtime.js';
7
+ /** Namespaced references only; credentials still belong in SecretStore. The pool remains caller-owned. */
8
+ export declare class PostgresAuthStore implements AuthStore {
9
+ readonly namespace: string;
10
+ private readonly db;
11
+ constructor(pool: Pool, namespace: string);
12
+ close(): Promise<void>;
13
+ get metrics(): {
14
+ rowsRead: number;
15
+ rowsWritten: number;
16
+ };
17
+ private select;
18
+ read(selection: AuthSelection): Promise<ClientAuthState>;
19
+ transaction<T>(selection: AuthSelection, change: (draft: ClientAuthState) => T): Promise<T>;
20
+ private put;
21
+ pendingOperations(after: string | undefined, limit: number): Promise<string[]>;
22
+ cleanup(before: number, limit?: number): Promise<number>;
23
+ }
24
+ export declare class PostgresConversationStore implements ConversationStore {
25
+ readonly namespace: string;
26
+ private readonly db;
27
+ constructor(pool: Pool, namespace: string);
28
+ close(): Promise<void>;
29
+ private outbound;
30
+ getOutbound(key: ConversationKey, id: string): Promise<OutboundRecord | undefined>;
31
+ prepareOutbound(key: ConversationKey, record: OutboundRecord): Promise<OutboundRecord>;
32
+ settleOutbound(key: ConversationKey, id: string, result: Pick<OutboundRecord, 'phase' | 'response' | 'error'>): Promise<void>;
33
+ removeOutbound(key: ConversationKey, id: string): Promise<void>;
34
+ private checkpointValues;
35
+ private checkpoint;
36
+ getCheckpoint(key: ConversationKey, conversation: string, name: string): Promise<string | undefined>;
37
+ advanceCheckpoint(key: ConversationKey, row: ConsumerCheckpoint, from: readonly (string | undefined)[]): Promise<void>;
38
+ resetCheckpoint(key: ConversationKey, conversation: string, name: string): Promise<void>;
39
+ }
@@ -0,0 +1,143 @@
1
+ import { initialClientAuthState } from './auth/client-auth.js';
2
+ import { PostgresRuntime, json, synchronous } from './postgres-runtime.js';
3
+ export { createPostgresPool, migratePostgres, postgresMigration, postgresSchemaVersion } from './postgres-runtime.js';
4
+ const tables = ['identities', 'connections', 'grants', 'operations', 'cleanup'];
5
+ /** Namespaced references only; credentials still belong in SecretStore. The pool remains caller-owned. */
6
+ export class PostgresAuthStore {
7
+ namespace;
8
+ db;
9
+ constructor(pool, namespace) {
10
+ this.namespace = namespace;
11
+ this.db = new PostgresRuntime(pool, 'poppy_client');
12
+ }
13
+ close() { return this.db.close(); }
14
+ get metrics() { return this.db.metrics; }
15
+ async select(selection) {
16
+ // Epoch/owner are shared by this auth namespace and must fence every connection mutation.
17
+ await this.db.lock(`${this.namespace}:auth`);
18
+ const get = async (table, id) => (await this.db.query('SELECT body FROM poppy_client.records WHERE ns=$1 AND kind=$2 AND id=$3', [this.namespace, table, id]))[0];
19
+ const meta = await get('auth_meta', 'meta');
20
+ const initial = initialClientAuthState();
21
+ if (!meta)
22
+ await this.put('auth_meta', 'meta', { version: initial.version, epoch: initial.epoch, owner: initial.owner });
23
+ const state = { ...(meta ? JSON.parse(meta.body) : { version: initial.version, epoch: initial.epoch, owner: initial.owner }), identities: {}, connections: {}, grants: {}, operations: {}, cleanup: {} };
24
+ const one = async (table, id) => { const row = await get(table, id); if (row)
25
+ state[table][id] = JSON.parse(row.body); };
26
+ const rows = async (table, where, values = []) => {
27
+ for (const row of await this.db.query(`SELECT id,body FROM poppy_client.records WHERE ns=$1 AND kind=$2 ${where}`, [this.namespace, table, ...values]))
28
+ state[table][row.id] = JSON.parse(row.body);
29
+ };
30
+ if ('issuer' in selection)
31
+ await one('identities', selection.issuer);
32
+ else {
33
+ if ('connectionId' in selection)
34
+ await one('connections', selection.connectionId);
35
+ else if ('operationId' in selection)
36
+ await one('operations', selection.operationId);
37
+ else if ('callbackHash' in selection)
38
+ await rows('operations', "AND body::jsonb#>>'{direct,stateHash}'=$3", [selection.callbackHash]);
39
+ else
40
+ await rows('connections', '');
41
+ for (const operation of Object.values(state.operations))
42
+ await one('connections', operation.connectionId);
43
+ for (const connection of Object.values(state.connections)) {
44
+ if (connection.grantId)
45
+ await one('grants', connection.grantId);
46
+ if (!('list' in selection)) {
47
+ await rows('operations', "AND body::jsonb->>'connectionId'=$3", [connection.id]);
48
+ await rows('cleanup', "AND body::jsonb->>'connectionId'=$3", [connection.id]);
49
+ }
50
+ }
51
+ for (const operation of Object.values(state.operations))
52
+ if (operation.grantId)
53
+ await one('grants', operation.grantId);
54
+ }
55
+ return state;
56
+ }
57
+ read(selection) { return this.db.run(() => this.select(selection)); }
58
+ transaction(selection, change) {
59
+ return this.db.run(async () => {
60
+ const state = await this.select(selection), before = structuredClone(state), result = synchronous(change(state));
61
+ const { version, epoch, owner } = state;
62
+ await this.put('auth_meta', 'meta', { version, epoch, owner });
63
+ for (const table of tables) {
64
+ for (const id of Object.keys(before[table]))
65
+ if (!Object.hasOwn(state[table], id))
66
+ await this.db.query('DELETE FROM poppy_client.records WHERE ns=$1 AND kind=$2 AND id=$3', [this.namespace, table, id]);
67
+ for (const [id, value] of Object.entries(state[table]))
68
+ if (json(value) !== json(before[table][id] ?? null))
69
+ await this.put(table, id, value);
70
+ }
71
+ return result;
72
+ });
73
+ }
74
+ async put(kind, id, value) {
75
+ await this.db.query('INSERT INTO poppy_client.records(ns,kind,id,body) VALUES($1,$2,$3,$4) ON CONFLICT(ns,kind,id) DO UPDATE SET body=excluded.body', [this.namespace, kind, id, json(value)]);
76
+ this.db.metrics.rowsWritten++;
77
+ }
78
+ async pendingOperations(after, limit) {
79
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
80
+ throw new TypeError('recovery limit');
81
+ return this.db.run(async () => (await this.db.query("SELECT id FROM poppy_client.records WHERE ns=$1 AND kind='operations' AND body::jsonb->>'phase'!='terminal' AND id>$2 ORDER BY id LIMIT $3", [this.namespace, after ?? '', limit])).map(row => row.id));
82
+ }
83
+ async cleanup(before, limit = 100) {
84
+ if (!Number.isFinite(before) || !Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
85
+ throw new TypeError('cleanup limit');
86
+ return this.db.run(async () => { await this.db.lock(`${this.namespace}:auth`); return (await this.db.query("DELETE FROM poppy_client.records WHERE ns=$1 AND kind='operations' AND id IN (SELECT id FROM poppy_client.records WHERE ns=$1 AND kind='operations' AND body::jsonb->>'phase'='terminal' AND body::jsonb->>'status'!='uncertain' AND (body::jsonb->>'createdAt')::double precision<$2 ORDER BY (body::jsonb->>'createdAt')::double precision LIMIT $3) RETURNING id", [this.namespace, before, limit])).length; });
87
+ }
88
+ }
89
+ export class PostgresConversationStore {
90
+ namespace;
91
+ db;
92
+ constructor(pool, namespace) {
93
+ this.namespace = namespace;
94
+ this.db = new PostgresRuntime(pool, 'poppy_client');
95
+ }
96
+ close() { return this.db.close(); }
97
+ async outbound(key, id) {
98
+ const row = (await this.db.query('SELECT body FROM poppy_client.outbound WHERE ns=$1 AND connection=$2 AND issuer=$3 AND id=$4', [this.namespace, key.connectionId, key.issuer, id]))[0];
99
+ return row ? JSON.parse(row.body) : undefined;
100
+ }
101
+ getOutbound(key, id) { return this.db.run(() => this.outbound(key, id)); }
102
+ async prepareOutbound(key, record) {
103
+ if (record.connectionId !== key.connectionId || record.issuer !== key.issuer || record.endpoint !== key.endpoint)
104
+ throw new TypeError('outbound binding');
105
+ return this.db.run(async () => {
106
+ await this.db.query('INSERT INTO poppy_client.outbound VALUES($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING', [this.namespace, key.connectionId, key.issuer, record.id, json(record)]);
107
+ return (await this.outbound(key, record.id));
108
+ });
109
+ }
110
+ settleOutbound(key, id, result) {
111
+ return this.db.run(async () => {
112
+ await this.db.lock(JSON.stringify([this.namespace, 'outbound', key.connectionId, key.issuer, id]));
113
+ const record = await this.outbound(key, id);
114
+ if (!record || record.endpoint !== key.endpoint)
115
+ throw new TypeError('outbound binding');
116
+ if (record.phase === 'pending')
117
+ await this.db.query('UPDATE poppy_client.outbound SET body=$5 WHERE ns=$1 AND connection=$2 AND issuer=$3 AND id=$4', [this.namespace, key.connectionId, key.issuer, id, json({ ...record, ...result })]);
118
+ });
119
+ }
120
+ removeOutbound(key, id) {
121
+ return this.db.run(async () => {
122
+ await this.db.lock(JSON.stringify([this.namespace, 'outbound', key.connectionId, key.issuer, id]));
123
+ const record = await this.outbound(key, id);
124
+ if (record && record.endpoint !== key.endpoint)
125
+ throw new TypeError('outbound binding');
126
+ await this.db.query('DELETE FROM poppy_client.outbound WHERE ns=$1 AND connection=$2 AND issuer=$3 AND id=$4', [this.namespace, key.connectionId, key.issuer, id]);
127
+ });
128
+ }
129
+ checkpointValues(key, conversation, name) { return [this.namespace, key.connectionId, key.issuer, key.endpoint, conversation, name]; }
130
+ async checkpoint(key, conversation, name) { return (await this.db.query('SELECT cursor FROM poppy_client.checkpoints WHERE ns=$1 AND connection=$2 AND issuer=$3 AND endpoint=$4 AND conversation=$5 AND name=$6', this.checkpointValues(key, conversation, name)))[0]?.cursor; }
131
+ getCheckpoint(key, conversation, name) { return this.db.run(() => this.checkpoint(key, conversation, name)); }
132
+ async advanceCheckpoint(key, row, from) {
133
+ if (row.connectionId !== key.connectionId || row.issuer !== key.issuer)
134
+ throw new TypeError('checkpoint binding');
135
+ return this.db.run(async () => {
136
+ const values = this.checkpointValues(key, row.conversationId, row.name);
137
+ await this.db.lock(JSON.stringify(['checkpoint', ...values]));
138
+ if (from.includes(await this.checkpoint(key, row.conversationId, row.name)))
139
+ await this.db.query('INSERT INTO poppy_client.checkpoints VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(ns,connection,issuer,endpoint,conversation,name) DO UPDATE SET cursor=excluded.cursor', [...values, row.cursor]);
140
+ });
141
+ }
142
+ resetCheckpoint(key, conversation, name) { return this.db.run(async () => { const values = this.checkpointValues(key, conversation, name); await this.db.lock(JSON.stringify(['checkpoint', ...values])); await this.db.query('DELETE FROM poppy_client.checkpoints WHERE ns=$1 AND connection=$2 AND issuer=$3 AND endpoint=$4 AND conversation=$5 AND name=$6', values); }); }
143
+ }
@@ -0,0 +1,15 @@
1
+ import type { Pool } from 'pg';
2
+ import type { ProviderSelection, ProviderStore, ProviderTransaction } from './provider.js';
3
+ export { createPostgresPool, migratePostgres, postgresMigration, postgresSchemaVersion } from './postgres-runtime.js';
4
+ /** Targeted provider aggregates. Cryptography and human/session verification run outside its transactions. */
5
+ export declare class PostgresProviderStore implements ProviderStore {
6
+ readonly namespace: string;
7
+ private readonly db;
8
+ constructor(pool: Pool, namespace: string);
9
+ close(): Promise<void>;
10
+ get metrics(): {
11
+ rowsRead: number;
12
+ rowsWritten: number;
13
+ };
14
+ transaction<T>(selection: ProviderSelection, action: (tx: ProviderTransaction) => T): Promise<T>;
15
+ }
@@ -0,0 +1,96 @@
1
+ import { providerMemoryTransaction, providerUserKey, scopedProviderTransaction } from './provider-storage.js';
2
+ import { PostgresRuntime, json, synchronous } from './postgres-runtime.js';
3
+ export { createPostgresPool, migratePostgres, postgresMigration, postgresSchemaVersion } from './postgres-runtime.js';
4
+ /** Targeted provider aggregates. Cryptography and human/session verification run outside its transactions. */
5
+ export class PostgresProviderStore {
6
+ namespace;
7
+ db;
8
+ constructor(pool, namespace) {
9
+ this.namespace = namespace;
10
+ this.db = new PostgresRuntime(pool, 'poppy_provider');
11
+ }
12
+ close() { return this.db.close(); }
13
+ get metrics() { return this.db.metrics; }
14
+ transaction(selection, action) {
15
+ return this.db.run(async () => {
16
+ const rows = new Map();
17
+ const one = async (kind, id) => {
18
+ const row = (await this.db.query('SELECT body FROM poppy_provider.records WHERE ns=$1 AND kind=$2 AND id=$3', [this.namespace, kind, id]))[0];
19
+ const key = JSON.stringify([kind, id]);
20
+ rows.delete(key);
21
+ if (row)
22
+ rows.set(key, JSON.parse(row.body));
23
+ return row ? JSON.parse(row.body) : undefined;
24
+ };
25
+ const lock = (kind, id) => this.db.lock(JSON.stringify([this.namespace, kind, id]));
26
+ if (selection.kind === 'enroll') {
27
+ await lock('capacity', 'enrollments');
28
+ await one('capacity', 'enrollments');
29
+ await one('visitor', selection.subjectHash);
30
+ await one('enrollmentRequest', selection.requestHash);
31
+ }
32
+ else {
33
+ const lookup = selection.kind === 'callback' ? await one('state', selection.stateHash) : undefined;
34
+ const enrollmentId = selection.kind === 'callback' ? lookup?.enrollmentId : selection.enrollmentId;
35
+ if (enrollmentId) {
36
+ await lock('enrollment', enrollmentId);
37
+ const enrollment = await one('enrollment', enrollmentId);
38
+ if ('issuer' in selection && enrollment) {
39
+ const key = providerUserKey(enrollment.visitor, selection.issuer);
40
+ await lock('user', key);
41
+ await one('user', key);
42
+ }
43
+ if (selection.kind === 'begin' || selection.kind === 'revoke') {
44
+ const operations = await this.db.query("SELECT id,body FROM poppy_provider.records WHERE ns=$1 AND kind='operation' AND split_part(id,':',1)=$2 ORDER BY id LIMIT 1001", [this.namespace, enrollmentId]);
45
+ for (const row of operations)
46
+ rows.set(JSON.stringify(['operation', row.id]), JSON.parse(row.body));
47
+ if (operations.length > 1000)
48
+ throw new Error('provider aggregate exceeds retained operation limit');
49
+ if (selection.kind === 'begin') {
50
+ const states = new Set();
51
+ if (selection.stateHash)
52
+ states.add(selection.stateHash);
53
+ for (const row of operations) {
54
+ const hash = JSON.parse(row.body).binding?.stateHash;
55
+ if (hash)
56
+ states.add(hash);
57
+ }
58
+ for (const hash of [...states].sort()) {
59
+ await lock('state', hash);
60
+ await one('state', hash);
61
+ }
62
+ }
63
+ }
64
+ else {
65
+ if (selection.kind === 'callback') {
66
+ // Re-resolve after the enrollment lock; cleanup may have removed/rebound the selector.
67
+ const current = await one('state', selection.stateHash);
68
+ if (json(current ?? null) !== json(lookup ?? null))
69
+ throw new Error('provider callback selector changed');
70
+ }
71
+ const operationId = selection.kind === 'callback' ? lookup?.operationId : 'operationId' in selection ? selection.operationId : undefined;
72
+ if (operationId)
73
+ await one('operation', `${enrollmentId}:${operationId}`);
74
+ }
75
+ }
76
+ }
77
+ const before = new Map([...rows].map(([key, value]) => [key, json(value)]));
78
+ const result = synchronous(action(scopedProviderTransaction(selection, providerMemoryTransaction(rows))));
79
+ const removed = [...before.keys()].filter(key => !rows.has(key)).map(key => { const [kind, id] = JSON.parse(key); return { kind, id }; });
80
+ if (removed.length)
81
+ await this.db.query('DELETE FROM poppy_provider.records r USING jsonb_to_recordset($2::jsonb) AS d(kind text,id text) WHERE r.ns=$1 AND r.kind=d.kind AND r.id=d.id', [this.namespace, json(removed)]);
82
+ const changed = [];
83
+ for (const [key, value] of rows) {
84
+ const body = json(value);
85
+ if (before.get(key) === body)
86
+ continue;
87
+ const [kind, id] = JSON.parse(key);
88
+ changed.push({ kind, id, body });
89
+ }
90
+ if (changed.length)
91
+ await this.db.query('INSERT INTO poppy_provider.records(ns,kind,id,body) SELECT $1,kind,id,body FROM jsonb_to_recordset($2::jsonb) AS d(kind text,id text,body text) ON CONFLICT(ns,kind,id) DO UPDATE SET body=excluded.body', [this.namespace, json(changed)]);
92
+ this.metrics.rowsWritten += removed.length + changed.length;
93
+ return result;
94
+ });
95
+ }
96
+ }
@@ -1,8 +1,8 @@
1
- import type { ProviderStore, ProviderTransaction } from './provider.js';
1
+ import type { ProviderSelection, ProviderStore, ProviderTransaction } from './provider.js';
2
2
  /** Optional durable provider records. Callback results must be encrypted by the separately configured cipher. */
3
3
  export declare class SqliteProviderStore implements ProviderStore {
4
4
  private readonly db;
5
5
  constructor(path: string);
6
6
  close(): void;
7
- transaction<T>(action: (tx: ProviderTransaction) => T): T;
7
+ transaction<T>(selection: ProviderSelection, action: (tx: ProviderTransaction) => T): Promise<T>;
8
8
  }
@@ -1,26 +1,29 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
2
  import { initializeSqlite } from './sqlite-init.js';
3
+ import { scopedProviderTransaction } from './provider-storage.js';
3
4
  /** Optional durable provider records. Callback results must be encrypted by the separately configured cipher. */
4
5
  export class SqliteProviderStore {
5
6
  db;
6
7
  constructor(path) {
7
8
  this.db = new DatabaseSync(path);
8
9
  initializeSqlite(this.db, () => this.db.exec('PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; CREATE TABLE IF NOT EXISTS provider_records(kind TEXT NOT NULL,id TEXT NOT NULL,body TEXT NOT NULL,PRIMARY KEY(kind,id));'));
10
+ this.db.exec(`INSERT OR IGNORE INTO provider_records SELECT 'capacity','enrollments',json_object('count',count(*)) FROM provider_records WHERE kind='enrollment'`);
9
11
  }
10
12
  close() { this.db.close(); }
11
- transaction(action) {
13
+ async transaction(selection, action) {
12
14
  this.db.exec('BEGIN IMMEDIATE');
13
15
  try {
14
- const result = action({
16
+ const result = action(scopedProviderTransaction(selection, {
15
17
  get: (kind, id) => { const row = this.db.prepare('SELECT body FROM provider_records WHERE kind=? AND id=?').get(kind, id); return row ? JSON.parse(row.body) : undefined; },
16
18
  put: (kind, id, value) => { this.db.prepare('INSERT OR REPLACE INTO provider_records VALUES (?,?,?)').run(kind, id, JSON.stringify(value)); },
17
19
  remove: (kind, id) => { this.db.prepare('DELETE FROM provider_records WHERE kind=? AND id=?').run(kind, id); },
18
- list: (kind, prefix, limit) => this.db.prepare('SELECT id,body FROM provider_records WHERE kind=? AND id>=? AND id<? ORDER BY id LIMIT ?').all(kind, prefix, `${prefix}\uffff`, limit).map(row => ({ id: row.id, value: JSON.parse(row.body) })),
19
- });
20
- if (result instanceof Promise)
20
+ list: (kind, prefix, limit) => this.db.prepare('SELECT id,body FROM provider_records WHERE kind=? AND id>=? AND id<? ORDER BY id LIMIT ?').all(kind, prefix, `${prefix.slice(0, -1)};`, limit).map(row => ({ id: row.id, value: JSON.parse(row.body) })),
21
+ }));
22
+ if (result && typeof result.then === 'function')
21
23
  throw new TypeError('provider transaction must be synchronous');
24
+ const detached = structuredClone(result);
22
25
  this.db.exec('COMMIT');
23
- return result;
26
+ return detached;
24
27
  }
25
28
  catch (error) {
26
29
  this.db.exec('ROLLBACK');
@@ -0,0 +1,7 @@
1
+ import type { ProviderSelection, ProviderTransaction } from './provider.js';
2
+ type Row = Record<string, any>;
3
+ export declare const providerUserKey: (visitor: string, issuer: string) => string;
4
+ /** Enforces the same aggregate boundary for local, remote and custom stores. */
5
+ export declare function scopedProviderTransaction(selection: ProviderSelection, raw: ProviderTransaction): ProviderTransaction;
6
+ export declare function providerMemoryTransaction(rows: Map<string, Row>): ProviderTransaction;
7
+ export {};
@@ -0,0 +1,61 @@
1
+ import { createHash } from 'node:crypto';
2
+ export const providerUserKey = (visitor, issuer) => createHash('sha256').update(JSON.stringify([visitor, issuer])).digest('base64url');
3
+ /** Enforces the same aggregate boundary for local, remote and custom stores. */
4
+ export function scopedProviderTransaction(selection, raw) {
5
+ let enrollmentId = 'enrollmentId' in selection ? selection.enrollmentId : undefined;
6
+ let operationId = 'operationId' in selection ? selection.operationId : undefined;
7
+ if (selection.kind === 'callback') {
8
+ const lookup = raw.get('state', selection.stateHash);
9
+ enrollmentId = lookup?.enrollmentId;
10
+ operationId = lookup?.operationId;
11
+ }
12
+ const enrollment = enrollmentId ? raw.get('enrollment', enrollmentId) : undefined;
13
+ const userKey = 'issuer' in selection && enrollment ? providerUserKey(enrollment.visitor, selection.issuer) : undefined;
14
+ const states = new Set();
15
+ if (selection.kind === 'callback')
16
+ states.add(selection.stateHash);
17
+ if (selection.kind === 'begin') {
18
+ if (selection.stateHash)
19
+ states.add(selection.stateHash);
20
+ for (const row of raw.list('operation', `${enrollmentId}:`, 1001))
21
+ if (row.value.binding?.stateHash)
22
+ states.add(row.value.binding.stateHash);
23
+ }
24
+ let createdEnrollment;
25
+ const check = (kind, id, write = false) => {
26
+ let allowed = false;
27
+ if (selection.kind === 'enroll') {
28
+ allowed = (kind === 'capacity' && id === 'enrollments') || (kind === 'visitor' && id === selection.subjectHash) || (kind === 'enrollmentRequest' && id === selection.requestHash);
29
+ if (kind === 'enrollment' && write && (!createdEnrollment || createdEnrollment === id) && !raw.get(kind, id)) {
30
+ createdEnrollment = id;
31
+ allowed = true;
32
+ }
33
+ }
34
+ else {
35
+ allowed = (kind === 'enrollment' && id === enrollmentId) || (kind === 'user' && id === userKey) || (kind === 'state' && states.has(id));
36
+ if (kind === 'operation')
37
+ allowed = (selection.kind === 'begin' || selection.kind === 'revoke') ? id.startsWith(`${enrollmentId}:`) : id === `${enrollmentId}:${operationId}`;
38
+ }
39
+ if (!allowed)
40
+ throw new TypeError('provider access outside selection');
41
+ };
42
+ return {
43
+ get(kind, id) { check(kind, id); return raw.get(kind, id); },
44
+ put(kind, id, value) { check(kind, id, true); raw.put(kind, id, value); },
45
+ remove(kind, id) { check(kind, id); raw.remove(kind, id); },
46
+ list(kind, prefix, limit) {
47
+ if (!['begin', 'revoke'].includes(selection.kind) || kind !== 'operation' || prefix !== `${enrollmentId}:` || !Number.isSafeInteger(limit) || limit < 1 || limit > 1001)
48
+ throw new TypeError('provider list outside selection');
49
+ return raw.list(kind, prefix, limit);
50
+ },
51
+ };
52
+ }
53
+ export function providerMemoryTransaction(rows) {
54
+ const key = (kind, id) => JSON.stringify([kind, id]);
55
+ return {
56
+ get: (kind, id) => rows.get(key(kind, id)),
57
+ put: (kind, id, value) => { rows.set(key(kind, id), value); },
58
+ remove: (kind, id) => { rows.delete(key(kind, id)); },
59
+ list: (kind, prefix, limit) => [...rows].map(([key, value]) => ({ key: JSON.parse(key), value })).filter(row => row.key[0] === kind && row.key[1].startsWith(prefix)).sort((a, b) => a.key[1].localeCompare(b.key[1])).slice(0, limit).map(row => ({ id: row.key[1], value: row.value })),
60
+ };
61
+ }
@@ -11,10 +11,40 @@ export interface ProviderTransaction {
11
11
  value: Row;
12
12
  }[];
13
13
  }
14
- /** Must commit synchronously, atomically across processes; callback must not perform I/O. */
14
+ export type ProviderSelection = {
15
+ kind: 'enroll';
16
+ subjectHash: string;
17
+ requestHash: string;
18
+ } | {
19
+ kind: 'identity';
20
+ enrollmentId: string;
21
+ issuer: string;
22
+ } | {
23
+ kind: 'operation';
24
+ enrollmentId: string;
25
+ operationId: string;
26
+ } | {
27
+ kind: 'begin';
28
+ enrollmentId: string;
29
+ operationId: string;
30
+ issuer: string;
31
+ stateHash?: string;
32
+ at: number;
33
+ } | {
34
+ kind: 'callback';
35
+ stateHash: string;
36
+ } | {
37
+ kind: 'revoke';
38
+ enrollmentId: string;
39
+ };
40
+ /** Atomic asynchronous I/O around a pure synchronous, selection-bounded callback. */
15
41
  export interface ProviderStore {
16
- transaction<T>(action: (tx: ProviderTransaction) => T): T;
42
+ transaction<T>(selection: ProviderSelection, action: (tx: ProviderTransaction) => T): Promise<T>;
17
43
  }
44
+ /** Explicit migration aid for existing local synchronous stores; unsuitable for remote I/O. */
45
+ export declare function adaptLegacyProviderStore(store: {
46
+ transaction<T>(action: (tx: ProviderTransaction) => T): T;
47
+ }): ProviderStore;
18
48
  export interface ResultCipher {
19
49
  seal(value: string): string;
20
50
  open(value: string): string;
@@ -70,22 +100,22 @@ export declare function createProvider(options: ProviderOptions): {
70
100
  assertion: string;
71
101
  expiresAt: number;
72
102
  }>;
73
- callback(callbackUrl: string): {
103
+ callback(callbackUrl: string): Promise<{
74
104
  status: string;
75
- };
76
- takeResult(credential: string, input: Row): {
105
+ }>;
106
+ takeResult(credential: string, input: Row): Promise<{
77
107
  status: string;
78
108
  callbackUrl?: undefined;
79
109
  } | {
80
110
  status: string;
81
111
  callbackUrl: string;
82
- };
83
- cancel(credential: string, input: Row): {
112
+ }>;
113
+ cancel(credential: string, input: Row): Promise<{
84
114
  status: string;
85
- };
86
- revokeEnrollment(credential: string, input: Row): {
115
+ }>;
116
+ revokeEnrollment(credential: string, input: Row): Promise<{
87
117
  status: string;
88
- };
118
+ }>;
89
119
  handle(request: Request): Promise<Response>;
90
120
  };
91
121
  /** Bound trusted adapter: credentials/assertions/results never belong in harness output. */
package/dist/provider.js CHANGED
@@ -1,6 +1,17 @@
1
1
  /** Optional PoppyKit provider v1. Hosting, human authentication and key custody belong to the operator. */
2
2
  import { createHash, randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
3
3
  import { secureUrl } from './network/transport.js';
4
+ /** Explicit migration aid for existing local synchronous stores; unsuitable for remote I/O. */
5
+ export function adaptLegacyProviderStore(store) {
6
+ return { async transaction(_selection, action) {
7
+ return store.transaction(tx => {
8
+ const result = action(tx);
9
+ if (result && typeof result.then === 'function')
10
+ throw new TypeError('provider transaction must be synchronous');
11
+ return structuredClone(result);
12
+ });
13
+ } };
14
+ }
4
15
  export function createResultCipher(key) {
5
16
  if (key.length !== 32)
6
17
  throw new Error('provider encryption key must be 32 bytes');
@@ -85,10 +96,12 @@ export function createProvider(options) {
85
96
  starts.push(now());
86
97
  tx.put('enrollment', enrollment.enrollmentId, { ...enrollment, starts });
87
98
  }
88
- function authorizeCompany(credential, input) {
99
+ const enrollmentId = (credential) => credential.split('.')[0] ?? '';
100
+ const selectedOperation = (credential, input) => ({ kind: 'operation', enrollmentId: enrollmentId(credential), operationId: text(input.operationId) });
101
+ async function authorizeCompany(credential, input) {
89
102
  const domain = text(input.domain);
90
103
  text(input.issuer);
91
- store.transaction(tx => { const enrollment = auth(tx, credential, text(input.installationId)); if (!enrollment.allowedCompanies.includes(domain))
104
+ await store.transaction({ kind: 'identity', enrollmentId: enrollmentId(credential), issuer: input.issuer }, tx => { const enrollment = auth(tx, credential, text(input.installationId)); if (!enrollment.allowedCompanies.includes(domain))
92
105
  fail('unauthorized'); });
93
106
  }
94
107
  const api = {
@@ -98,12 +111,14 @@ export function createProvider(options) {
98
111
  if (!human?.subject || !human.allowedCompanies.length)
99
112
  fail('unauthorized');
100
113
  const installationId = text(input.installationId), key = hash(JSON.stringify([human.subject, text(input.idempotencyKey)]));
101
- return store.transaction(tx => {
114
+ return store.transaction({ kind: 'enroll', subjectHash: hash(human.subject), requestHash: key }, tx => {
102
115
  if (tx.get('enrollmentRequest', key))
103
116
  fail('consumed'); // Credential delivery cannot be replayed from a verifier.
104
117
  const subjectKey = hash(human.subject), visitor = tx.get('visitor', subjectKey)?.id ?? id();
105
- if (tx.list('enrollment', '', (options.maxEnrollments ?? 10000) + 1).length >= (options.maxEnrollments ?? 10000))
118
+ const count = tx.get('capacity', 'enrollments')?.count ?? 0;
119
+ if (count >= (options.maxEnrollments ?? 10000))
106
120
  throw new ProviderError('rate_limited', now() + 60000);
121
+ tx.put('capacity', 'enrollments', { count: count + 1 });
107
122
  tx.put('visitor', subjectKey, { id: visitor });
108
123
  const enrollmentId = id(), secret = id();
109
124
  tx.put('enrollment', enrollmentId, { installationId, visitor, verifier: hash(secret), allowedCompanies: [...new Set(human.allowedCompanies)], revoked: false, starts: [] });
@@ -113,9 +128,9 @@ export function createProvider(options) {
113
128
  },
114
129
  async identity(credential, input) {
115
130
  exact(input, ['version', 'installationId', 'domain', 'issuer']);
116
- authorizeCompany(credential, input);
131
+ await authorizeCompany(credential, input);
117
132
  const metadata = await verified(text(input.domain), text(input.issuer));
118
- return store.transaction(tx => {
133
+ return store.transaction({ kind: 'identity', enrollmentId: enrollmentId(credential), issuer: input.issuer }, tx => {
119
134
  const enrollment = auth(tx, credential, text(input.installationId));
120
135
  if (!enrollment.allowedCompanies.includes(metadata.document.organization.domain))
121
136
  fail('unauthorized');
@@ -129,7 +144,7 @@ export function createProvider(options) {
129
144
  text(input.operationId);
130
145
  if (input.sessionId !== undefined)
131
146
  text(input.sessionId);
132
- authorizeCompany(credential, input);
147
+ await authorizeCompany(credential, input);
133
148
  if (input.purpose === 'client-authentication' && (input.sessionId !== undefined || input.returnTo !== undefined))
134
149
  fail('binding_conflict');
135
150
  if (input.purpose !== 'browser' && input.returnTo !== undefined)
@@ -145,7 +160,7 @@ export function createProvider(options) {
145
160
  const metadata = await verified(text(input.domain), text(input.issuer));
146
161
  if (input.purpose === 'browser' && (!onDomain(input.returnTo, metadata.document.organization.domain) || !metadata.document.web?.browser_session_endpoint))
147
162
  fail('binding_conflict');
148
- const identity = store.transaction(tx => {
163
+ const identity = await store.transaction({ kind: 'identity', enrollmentId: enrollmentId(credential), issuer: input.issuer }, tx => {
149
164
  const enrollment = auth(tx, credential, text(input.installationId));
150
165
  if (!enrollment.allowedCompanies.includes(metadata.document.organization.domain))
151
166
  fail('unauthorized');
@@ -154,7 +169,7 @@ export function createProvider(options) {
154
169
  if (input.purpose === 'browser' && !await options.verifySession?.({ clientId: options.clientId, userId: identity.userId, issuer: input.issuer, sessionId: input.sessionId }))
155
170
  fail('unauthorized');
156
171
  const binding = { issuer: input.issuer, domain: metadata.document.organization.domain, purpose: input.purpose, sessionId: input.sessionId ?? null, returnTo: input.returnTo ?? null, stateHash: input.state ? hash(input.state) : null, userId: identity.userId, browserEndpoint: metadata.document.web?.browser_session_endpoint ?? null };
157
- return store.transaction(tx => {
172
+ return store.transaction({ kind: 'begin', enrollmentId: enrollmentId(credential), operationId: input.operationId, issuer: input.issuer, ...(binding.stateHash ? { stateHash: binding.stateHash } : {}), at: now() }, tx => {
158
173
  const enrollment = auth(tx, credential, text(input.installationId)), key = `${enrollment.enrollmentId}:${text(input.operationId)}`, existing = tx.get('operation', key);
159
174
  if (existing) {
160
175
  if (!same(existing.binding, binding))
@@ -186,7 +201,7 @@ export function createProvider(options) {
186
201
  },
187
202
  async assert(credential, input) {
188
203
  exact(input, ['version', 'installationId', 'operationId', 'purpose']);
189
- const snapshot = store.transaction(tx => {
204
+ const snapshot = await store.transaction(selectedOperation(credential, input), tx => {
190
205
  const enrollment = auth(tx, credential, text(input.installationId)), op = operation(tx, enrollment, text(input.operationId));
191
206
  if (op.binding.purpose !== input.purpose || !['client-authentication', 'session', 'browser'].includes(input.purpose))
192
207
  fail('binding_conflict');
@@ -206,17 +221,17 @@ export function createProvider(options) {
206
221
  if (signature.length !== 64)
207
222
  fail('unavailable');
208
223
  // A revoke/cancel racing external KMS signing wins before the assertion leaves the provider.
209
- store.transaction(tx => { const enrollment = auth(tx, credential, input.installationId), op = operation(tx, enrollment, input.operationId); tx.put('operation', `${enrollment.enrollmentId}:${input.operationId}`, { ...op, status: 'signed' }); });
224
+ await store.transaction(selectedOperation(credential, input), tx => { const enrollment = auth(tx, credential, input.installationId), op = operation(tx, enrollment, input.operationId); tx.put('operation', `${enrollment.enrollmentId}:${input.operationId}`, { ...op, status: 'signed' }); });
210
225
  return { assertion: `${unsigned}.${Buffer.from(signature).toString('base64url')}`, expiresAt: exp };
211
226
  },
212
- callback(callbackUrl) {
227
+ async callback(callbackUrl) {
213
228
  const url = new URL(callbackUrl), base = new URL(options.callbackUrl);
214
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))
215
230
  fail('binding_conflict');
216
231
  const state = text(url.searchParams.get('state')), issuer = text(url.searchParams.get('iss'));
217
232
  if (!!url.searchParams.get('code') === !!url.searchParams.get('error') || callbackUrl.length > 8192)
218
233
  fail('binding_conflict');
219
- return store.transaction(tx => {
234
+ return store.transaction({ kind: 'callback', stateHash: hash(state) }, tx => {
220
235
  const lookup = tx.get('state', hash(state));
221
236
  if (!lookup)
222
237
  fail('binding_conflict');
@@ -236,9 +251,9 @@ export function createProvider(options) {
236
251
  return { status: 'ready' };
237
252
  });
238
253
  },
239
- takeResult(credential, input) {
254
+ async takeResult(credential, input) {
240
255
  exact(input, ['version', 'installationId', 'operationId', 'issuer', 'sessionId']);
241
- return store.transaction(tx => {
256
+ return store.transaction(selectedOperation(credential, input), tx => {
242
257
  const enrollment = auth(tx, credential, text(input.installationId)), op = operation(tx, enrollment, text(input.operationId));
243
258
  if (op.binding.issuer !== input.issuer || op.binding.sessionId !== input.sessionId || op.binding.purpose !== 'direct')
244
259
  fail('binding_conflict');
@@ -249,9 +264,9 @@ export function createProvider(options) {
249
264
  return { status: 'completed', callbackUrl };
250
265
  });
251
266
  },
252
- cancel(credential, input) {
267
+ async cancel(credential, input) {
253
268
  exact(input, ['version', 'installationId', 'operationId']);
254
- return store.transaction(tx => {
269
+ return store.transaction(selectedOperation(credential, input), tx => {
255
270
  const enrollment = auth(tx, credential, text(input.installationId)), key = `${enrollment.enrollmentId}:${text(input.operationId)}`, op = tx.get('operation', key);
256
271
  if (!op)
257
272
  fail('binding_conflict');
@@ -259,9 +274,9 @@ export function createProvider(options) {
259
274
  return { status: 'canceled' };
260
275
  });
261
276
  },
262
- revokeEnrollment(credential, input) {
277
+ async revokeEnrollment(credential, input) {
263
278
  exact(input, ['version', 'installationId']);
264
- return store.transaction(tx => {
279
+ return store.transaction({ kind: 'revoke', enrollmentId: enrollmentId(credential) }, tx => {
265
280
  const enrollment = auth(tx, credential, text(input.installationId));
266
281
  tx.put('enrollment', enrollment.enrollmentId, { ...enrollment, revoked: true });
267
282
  for (const row of tx.list('operation', `${enrollment.enrollmentId}:`, 1000))
@@ -278,7 +293,7 @@ export function createProvider(options) {
278
293
  return Response.json({ keys: (options.publishedKeys ? await options.publishedKeys() : [{ ...await options.signer.publicJwk(), kid: options.signer.kid }]).map(publicKey) }, { headers });
279
294
  const url = new URL(request.url);
280
295
  if (url.origin === new URL(options.callbackUrl).origin && url.pathname === new URL(options.callbackUrl).pathname && request.method === 'GET') {
281
- api.callback(request.url);
296
+ await api.callback(request.url);
282
297
  return Response.json({ status: 'received' }, { headers });
283
298
  }
284
299
  if (request.method !== 'POST' || !request.headers.get('content-type')?.startsWith('application/json'))
@@ -0,0 +1,16 @@
1
+ CREATE SCHEMA poppy_client;
2
+ CREATE TABLE poppy_client.schema_version (version integer PRIMARY KEY CHECK(version > 0));
3
+ INSERT INTO poppy_client.schema_version VALUES (1);
4
+ CREATE TABLE poppy_client.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_client.records (ns,kind,(body::jsonb->>'conversationId'));
6
+ CREATE INDEX by_binding ON poppy_client.records (ns,kind,(body::jsonb->>'bindingId'));
7
+ CREATE INDEX by_client ON poppy_client.records (ns,kind,(body::jsonb->>'clientId'));
8
+ CREATE INDEX by_account ON poppy_client.records (ns,kind,(body::jsonb->>'accountId'));
9
+ CREATE INDEX by_grant ON poppy_client.records (ns,kind,(body::jsonb->>'grantId'));
10
+ CREATE INDEX by_phase ON poppy_client.records (ns,kind,(body::jsonb->>'phase'));
11
+ CREATE INDEX by_expiry ON poppy_client.records (ns,kind,((body::jsonb->>'expiresAt')::double precision));
12
+ CREATE INDEX by_connection ON poppy_client.records (ns,kind,(body::jsonb->>'connectionId'));
13
+ CREATE INDEX by_callback ON poppy_client.records (ns,kind,(body::jsonb#>>'{direct,stateHash}'));
14
+ CREATE TABLE poppy_client.outbound (ns text NOT NULL, connection text NOT NULL, issuer text NOT NULL, id text NOT NULL, body text NOT NULL, PRIMARY KEY(ns,connection,issuer,id));
15
+ CREATE TABLE poppy_client.checkpoints (ns text NOT NULL, connection text NOT NULL, issuer text NOT NULL, endpoint text NOT NULL, conversation text NOT NULL, name text NOT NULL, cursor text NOT NULL, PRIMARY KEY(ns,connection,issuer,endpoint,conversation,name));
16
+ CREATE UNIQUE INDEX auth_callback ON poppy_client.records(ns,((body::jsonb)#>>'{direct,stateHash}')) WHERE kind='operations';
@@ -0,0 +1,14 @@
1
+ CREATE SCHEMA poppy_provider;
2
+ CREATE TABLE poppy_provider.schema_version (version integer PRIMARY KEY CHECK(version > 0));
3
+ INSERT INTO poppy_provider.schema_version VALUES (1);
4
+ CREATE TABLE poppy_provider.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 operations_enrollment ON poppy_provider.records(ns,(split_part(id,':',1)),id) WHERE kind='operation';
6
+ CREATE INDEX by_conversation ON poppy_provider.records (ns,kind,(body::jsonb->>'conversationId'));
7
+ CREATE INDEX by_binding ON poppy_provider.records (ns,kind,(body::jsonb->>'bindingId'));
8
+ CREATE INDEX by_client ON poppy_provider.records (ns,kind,(body::jsonb->>'clientId'));
9
+ CREATE INDEX by_account ON poppy_provider.records (ns,kind,(body::jsonb->>'accountId'));
10
+ CREATE INDEX by_grant ON poppy_provider.records (ns,kind,(body::jsonb->>'grantId'));
11
+ CREATE INDEX by_phase ON poppy_provider.records (ns,kind,(body::jsonb->>'phase'));
12
+ CREATE INDEX by_expiry ON poppy_provider.records (ns,kind,((body::jsonb->>'expiresAt')::double precision));
13
+ CREATE INDEX by_connection ON poppy_provider.records (ns,kind,(body::jsonb->>'connectionId'));
14
+ CREATE INDEX by_callback ON poppy_provider.records (ns,kind,(body::jsonb#>>'{direct,stateHash}'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poppykit/client",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "description": "Personal Agent Protocol client SDK",
6
6
  "repository": {
@@ -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",
@@ -40,7 +42,8 @@
40
42
  "CHANGELOG.md",
41
43
  "RELEASING.md",
42
44
  "examples",
43
- "LICENSE"
45
+ "LICENSE",
46
+ "migrations"
44
47
  ],
45
48
  "exports": {
46
49
  ".": {
@@ -62,6 +65,18 @@
62
65
  "./provider/sqlite": {
63
66
  "import": "./dist/provider-sqlite.js",
64
67
  "types": "./dist/provider-sqlite.d.ts"
68
+ },
69
+ "./postgres": {
70
+ "types": "./dist/postgres.d.ts",
71
+ "import": "./dist/postgres.js"
72
+ },
73
+ "./provider/postgres": {
74
+ "types": "./dist/provider-postgres.d.ts",
75
+ "import": "./dist/provider-postgres.js"
76
+ },
77
+ "./adapter-contract": {
78
+ "types": "./dist/adapter-contract.d.ts",
79
+ "import": "./dist/adapter-contract.js"
65
80
  }
66
81
  },
67
82
  "dependencies": {
@@ -70,5 +85,13 @@
70
85
  "license": "MIT",
71
86
  "publishConfig": {
72
87
  "access": "public"
88
+ },
89
+ "peerDependencies": {
90
+ "pg": "^8.23.1"
91
+ },
92
+ "peerDependenciesMeta": {
93
+ "pg": {
94
+ "optional": true
95
+ }
73
96
  }
74
97
  }