@poppykit/client 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 +5 -1
- package/CHANGELOG.md +10 -0
- package/PROVIDER.md +35 -2
- package/README.md +3 -1
- package/STORAGE.md +60 -0
- package/dist/adapter-contract.d.ts +15 -0
- package/dist/adapter-contract.js +48 -0
- package/dist/postgres-runtime.d.ts +30 -0
- package/dist/postgres-runtime.js +172 -0
- package/dist/postgres.d.ts +39 -0
- package/dist/postgres.js +143 -0
- package/dist/provider-postgres.d.ts +15 -0
- package/dist/provider-postgres.js +96 -0
- package/dist/provider-sqlite.d.ts +2 -2
- package/dist/provider-sqlite.js +9 -6
- package/dist/provider-storage.d.ts +7 -0
- package/dist/provider-storage.js +61 -0
- package/dist/provider.d.ts +66 -10
- package/dist/provider.js +74 -20
- package/migrations/postgres/0001_poppy_client.sql +16 -0
- package/migrations/postgres/0001_poppy_provider.sql +14 -0
- package/package.json +26 -3
package/API.md
CHANGED
|
@@ -11,7 +11,7 @@ for complete option/result types. This map describes the supported integration s
|
|
|
11
11
|
| `@poppykit/client` | `ApiTarget`, `AgentBrowserController`, `FeedbackReport` types | [integrations](INTEGRATIONS.md) |
|
|
12
12
|
| `@poppykit/client/sqlite` | `SqliteAuthStore`, `SqliteConversationStore` | [storage](STORAGE.md) |
|
|
13
13
|
| `@poppykit/client/memory` | `MemoryConversationStore` (non-durable) | [storage](STORAGE.md) |
|
|
14
|
-
| `@poppykit/client/provider` | `createProvider`, `createProviderClient`, `createResultCipher` | [provider](PROVIDER.md) |
|
|
14
|
+
| `@poppykit/client/provider` | `createProvider`, `createProviderClient`, `createResultCipher`, `createBrowserEnrollmentClient` | [provider](PROVIDER.md) |
|
|
15
15
|
| `@poppykit/client/provider/sqlite` | `SqliteProviderStore` | [provider](PROVIDER.md) |
|
|
16
16
|
|
|
17
17
|
ClientAuth methods include `start/get/list/target`, `authorization`,
|
|
@@ -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,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 (release candidate)
|
|
4
|
+
|
|
5
|
+
Add the optional challenge-bound browser enrollment client under `/provider`.
|
|
6
|
+
Existing provider APIs, storage adapters and all protocol authentication methods
|
|
7
|
+
retain their contracts. Enrollment secrets remain caller-controlled.
|
|
8
|
+
|
|
9
|
+
## 0.2.0 (release candidate)
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
3
13
|
## 0.1.1
|
|
4
14
|
|
|
5
15
|
Documentation refresh: registry installation, runnable quickstarts, focused guides
|
package/PROVIDER.md
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
# Optional identity provider
|
|
2
2
|
|
|
3
|
+
`createBrowserEnrollmentClient` from this subpath is an optional hosting-service
|
|
4
|
+
client. It is independent of company consent and leaves all secret custody with
|
|
5
|
+
the caller. Configure an HTTPS enrollment URL on the provider origin; call
|
|
6
|
+
`start(SHA256(verifier))`, show only its public `browserUrl`, then call
|
|
7
|
+
`status(requestId, verifier)` from trusted code. A ready response contains the
|
|
8
|
+
credential for immediate secure staging, never for model output. `cancel` revokes
|
|
9
|
+
failed/uncertain delivery; hosts must consume results once, retain replay
|
|
10
|
+
tombstones and revoke on failed/lost retrieval. The CLI implements that workflow.
|
|
11
|
+
This optional service uses version1 JSON requests and is not a Poppy extension.
|
|
12
|
+
|
|
3
13
|
Import `createProvider`, `createProviderClient`, and `createResultCipher` from
|
|
4
14
|
`@poppykit/client/provider`; durable `SqliteProviderStore` is separately exported
|
|
5
15
|
from `@poppykit/client/provider/sqlite`. Neither is loaded by the core import.
|
|
6
|
-
|
|
16
|
+
The wire protocol remains PoppyKit provider **v1**; the 0.2 TypeScript API uses async storage, not a Personal Agent Protocol extension.
|
|
7
17
|
Node 24.12+ is required for the SQLite reference adapter.
|
|
8
18
|
|
|
9
19
|
An operator supplies HTTPS hosting, a provider client metadata URL, JWKS URL,
|
|
@@ -65,7 +75,7 @@ issuer, and exactly one code/error; result ciphertext is sealed atomically with
|
|
|
65
75
|
the immutable binding. Identical pending callbacks are harmless; conflicting,
|
|
66
76
|
canceled, expired and consumed callbacks fail. `takeResult` requires enrollment,
|
|
67
77
|
installation, operation, issuer and exact session; it consumes within the same
|
|
68
|
-
|
|
78
|
+
storage transaction before returning private callback data. Lost retrieval delivery
|
|
69
79
|
is uncertain, never a reason to exchange or retrieve again. SDK code owns PKCE and
|
|
70
80
|
the exchange. Never log request URLs or responses on this route.
|
|
71
81
|
|
|
@@ -97,3 +107,26 @@ Account Tokens or DPoP keys. Revoke company authorization **before** disabling
|
|
|
97
107
|
provider signing, or use authenticated company account controls afterward.
|
|
98
108
|
This is scoped authority, redaction and provider key custody; it does not isolate
|
|
99
109
|
authority from unrestricted programs running as the same OS user.
|
|
110
|
+
|
|
111
|
+
## Migrating provider code to 0.2
|
|
112
|
+
|
|
113
|
+
Await every provider operation, including `callback`, `takeResult`, `cancel` and
|
|
114
|
+
`revokeEnrollment`; HTTP callback success is returned only after storage commits.
|
|
115
|
+
Use rejected-Promise assertions for failures. Signer, enrollment authentication,
|
|
116
|
+
metadata and session verification stay outside storage transactions; ResultCipher
|
|
117
|
+
remains synchronous local cryptography.
|
|
118
|
+
|
|
119
|
+
`ProviderSelection` declares enroll, identity, operation, begin, callback or revoke.
|
|
120
|
+
Remote stores load and lock only that aggregate; out-of-selection access and thenable
|
|
121
|
+
callbacks reject. Enroll locks a namespace capacity counter; identity mapping locks
|
|
122
|
+
visitor/full-issuer keys across installations; callbacks re-resolve state after the
|
|
123
|
+
enrollment fence. Begin/revoke inspect at most 1,001 operation rows. All valid Unicode
|
|
124
|
+
operation IDs count toward limits and revocation. Existing SQLite databases retain
|
|
125
|
+
records and initialize the capacity counter from their enrollment rows.
|
|
126
|
+
|
|
127
|
+
For an existing local synchronous custom store, explicitly wrap it with
|
|
128
|
+
`adaptLegacyProviderStore(legacyStore)`. This migration helper ignores selections
|
|
129
|
+
and cannot turn local synchronous I/O into remote persistence. New remote/custom
|
|
130
|
+
stores implement the targeted contract directly. The bundled Postgres adapter is
|
|
131
|
+
`PostgresProviderStore` from `@poppykit/client/provider/postgres`; see
|
|
132
|
+
[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.3.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.
|
|
15
|
+
npm install @poppykit/client@0.3.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
|
+
}
|
package/dist/postgres.js
ADDED
|
@@ -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
|
}
|
package/dist/provider-sqlite.js
CHANGED
|
@@ -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}
|
|
19
|
-
});
|
|
20
|
-
if (result
|
|
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
|
|
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
|
+
}
|
package/dist/provider.d.ts
CHANGED
|
@@ -11,10 +11,40 @@ export interface ProviderTransaction {
|
|
|
11
11
|
value: Row;
|
|
12
12
|
}[];
|
|
13
13
|
}
|
|
14
|
-
|
|
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. */
|
|
@@ -118,4 +148,30 @@ export declare function createProviderClient(options: {
|
|
|
118
148
|
cancel: (operationId: string) => Promise<Row>;
|
|
119
149
|
revokeEnrollment: () => Promise<Row>;
|
|
120
150
|
};
|
|
151
|
+
/** Browser enrollment is an optional host service, separate from company consent. Keep verifier/results in trusted custody. */
|
|
152
|
+
export declare function createBrowserEnrollmentClient(options: {
|
|
153
|
+
url: string;
|
|
154
|
+
provider: string;
|
|
155
|
+
company: string;
|
|
156
|
+
installationId: string;
|
|
157
|
+
transport: HttpTransport;
|
|
158
|
+
}): {
|
|
159
|
+
start(challenge: string): Promise<{
|
|
160
|
+
requestId: string;
|
|
161
|
+
browserUrl: string;
|
|
162
|
+
expiresAt: number;
|
|
163
|
+
}>;
|
|
164
|
+
status(requestId: string, verifier: string): Promise<{
|
|
165
|
+
status: "pending" | "denied";
|
|
166
|
+
credential?: undefined;
|
|
167
|
+
clientId?: undefined;
|
|
168
|
+
callbackUrl?: undefined;
|
|
169
|
+
} | {
|
|
170
|
+
status: "ready";
|
|
171
|
+
credential: string;
|
|
172
|
+
clientId: string;
|
|
173
|
+
callbackUrl: string;
|
|
174
|
+
}>;
|
|
175
|
+
cancel: (requestId: string, verifier: string) => Promise<Row>;
|
|
176
|
+
};
|
|
121
177
|
export {};
|
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
|
-
|
|
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
|
-
|
|
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'))
|
|
@@ -340,3 +355,42 @@ export function createProviderClient(options) {
|
|
|
340
355
|
takeResult: (input) => call('takeResult', input),
|
|
341
356
|
cancel: (operationId) => call('cancel', { operationId }), revokeEnrollment: () => call('revokeEnrollment', {}) };
|
|
342
357
|
}
|
|
358
|
+
/** Browser enrollment is an optional host service, separate from company consent. Keep verifier/results in trusted custody. */
|
|
359
|
+
export function createBrowserEnrollmentClient(options) {
|
|
360
|
+
const base = https(options.url), provider = https(options.provider);
|
|
361
|
+
if (base.search || base.hash || provider.origin !== base.origin)
|
|
362
|
+
throw new TypeError('enrollment and provider must share origin');
|
|
363
|
+
async function call(action, input) {
|
|
364
|
+
const response = await options.transport.fetch(new Request(`${base.href.replace(/\/$/, '')}/${action}`, {
|
|
365
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
366
|
+
body: JSON.stringify({ version: 1, installationId: options.installationId, provider: options.provider, company: options.company, ...input }),
|
|
367
|
+
}));
|
|
368
|
+
const body = object(await response.json());
|
|
369
|
+
if (!response.ok)
|
|
370
|
+
throw new ProviderError(['unauthorized', 'binding_conflict', 'expired', 'consumed', 'canceled', 'rate_limited'].includes(body.error) ? body.error : 'unavailable');
|
|
371
|
+
return body;
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
async start(challenge) {
|
|
375
|
+
if (!/^[\w-]{43}$/.test(challenge))
|
|
376
|
+
throw new TypeError('enrollment challenge');
|
|
377
|
+
const body = await call('start', { challenge });
|
|
378
|
+
const browser = https(text(body.browserUrl));
|
|
379
|
+
if (browser.origin !== base.origin || typeof body.expiresAt !== 'number' || !/^[\w-]{43}$/.test(body.requestId))
|
|
380
|
+
fail('binding_conflict');
|
|
381
|
+
return { requestId: body.requestId, browserUrl: browser.href, expiresAt: body.expiresAt };
|
|
382
|
+
},
|
|
383
|
+
async status(requestId, verifier) {
|
|
384
|
+
const body = await call('status', { requestId, verifier });
|
|
385
|
+
if (!['pending', 'denied', 'ready'].includes(body.status))
|
|
386
|
+
fail('binding_conflict');
|
|
387
|
+
if (body.status !== 'ready')
|
|
388
|
+
return { status: body.status };
|
|
389
|
+
const clientId = https(text(body.clientId)).href, callbackUrl = https(text(body.callbackUrl)).href;
|
|
390
|
+
if (new URL(clientId).origin !== provider.origin || new URL(callbackUrl).origin !== provider.origin)
|
|
391
|
+
fail('binding_conflict');
|
|
392
|
+
return { status: 'ready', credential: text(body.credential), clientId, callbackUrl };
|
|
393
|
+
},
|
|
394
|
+
cancel: (requestId, verifier) => call('cancel', { requestId, verifier }),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
@@ -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.
|
|
3
|
+
"version": "0.3.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
|
}
|