@poppykit/client 0.1.0 → 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.
@@ -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.0",
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
  }