@poppykit/server 0.4.0 → 0.5.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.
Files changed (52) hide show
  1. package/API.md +10 -3
  2. package/AUTH.md +120 -124
  3. package/CHANGELOG.md +13 -1
  4. package/CONFORMANCE.md +73 -0
  5. package/CONTRIBUTING.md +12 -11
  6. package/DELIVERY.md +2 -0
  7. package/INTEGRATIONS.md +74 -81
  8. package/OPERATIONS.md +142 -0
  9. package/README.md +11 -20
  10. package/RELEASING.md +2 -0
  11. package/SECURITY.md +2 -0
  12. package/STORAGE.md +56 -17
  13. package/dist/adapter-contract.d.ts +18 -0
  14. package/dist/adapter-contract.js +79 -0
  15. package/dist/auth/authenticator.d.ts +3 -1
  16. package/dist/auth/authenticator.js +34 -29
  17. package/dist/auth/authority.d.ts +8 -2
  18. package/dist/auth/authority.js +7 -2
  19. package/dist/auth/session.d.ts +47 -0
  20. package/dist/auth/session.js +94 -0
  21. package/dist/browser.d.ts +15 -16
  22. package/dist/browser.js +65 -33
  23. package/dist/conversation/service.d.ts +11 -1
  24. package/dist/conversation/service.js +39 -9
  25. package/dist/feedback.d.ts +1 -1
  26. package/dist/feedback.js +5 -5
  27. package/dist/index.d.ts +5 -1
  28. package/dist/index.js +3 -0
  29. package/dist/mcp.d.ts +1 -1
  30. package/dist/mcp.js +8 -4
  31. package/dist/network/discovery.d.ts +31 -13
  32. package/dist/network/discovery.js +103 -66
  33. package/dist/operations.d.ts +115 -0
  34. package/dist/operations.js +212 -0
  35. package/dist/postgres.d.ts +6 -0
  36. package/dist/postgres.js +72 -4
  37. package/dist/protocol/operations.d.ts +51 -0
  38. package/dist/protocol/operations.js +98 -0
  39. package/dist/protocol/session.d.ts +28 -0
  40. package/dist/protocol/session.js +93 -0
  41. package/dist/protocol/sse.d.ts +1 -0
  42. package/dist/protocol/sse.js +1 -1
  43. package/dist/protocol/wire.d.ts +8 -4
  44. package/dist/protocol/wire.js +37 -15
  45. package/dist/reference-auth/index.d.ts +10 -16
  46. package/dist/reference-auth/index.js +225 -430
  47. package/dist/sqlite.d.ts +11 -0
  48. package/dist/sqlite.js +84 -4
  49. package/examples/operations.mjs +18 -0
  50. package/examples/quickstart.mjs +8 -5
  51. package/migrations/RETIREMENT.md +77 -0
  52. package/package.json +5 -2
package/dist/sqlite.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import type { BusinessOperationStore } from './operations.js';
2
+ import type { BrowserCookieStore } from './browser.js';
3
+ import type { SessionStore } from './auth/session.js';
1
4
  import type { ConversationStore } from './conversation/store.js';
2
5
  import type { IssuerStore } from './reference-auth/store.js';
3
6
  /** Share this instance between issuer and conversations so authorization and mutation commit together. */
@@ -20,7 +23,15 @@ export declare class SqliteCompanyDatabase {
20
23
  private put;
21
24
  private save;
22
25
  private meta;
26
+ businessOperations(namespace: string): BusinessOperationStore;
27
+ /** Same-database business effects only. The callback and operation receipt share the outer transaction. */
28
+ withTransaction<T>(action: (connection: {
29
+ run(sql: string, values?: (string | number | null)[]): void;
30
+ get(sql: string, values?: (string | number | null)[]): Record<string, unknown> | undefined;
31
+ }) => Promise<T>): Promise<T>;
23
32
  conversations(namespace: string): ConversationStore;
33
+ browserCookies(namespace: string): BrowserCookieStore;
34
+ sessions(namespace: string): SessionStore;
24
35
  issuer(namespace: string): IssuerStore;
25
36
  /** Bounded maintenance; never deletes message receipts, events, grants or work intent. */
26
37
  cleanup(namespace: string, now: number, limit?: number): Promise<number>;
package/dist/sqlite.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { DatabaseSync } from 'node:sqlite';
2
3
  import { initializeSqlite } from './sqlite-init.js';
3
4
  import { AsyncLocalStorage } from 'node:async_hooks';
@@ -117,6 +118,39 @@ export class SqliteCompanyDatabase {
117
118
  this.put(ns, kind, 'meta', value);
118
119
  return value;
119
120
  }
121
+ businessOperations(namespace) {
122
+ return { transaction: (operationId, action) => this.run(async () => {
123
+ const name = 'operation_' + ++this.savepoint;
124
+ this.db.exec('SAVEPOINT ' + name);
125
+ try {
126
+ const slot = { record: this.one(namespace, 'business_operations', operationId) };
127
+ if (slot.record && slot.record.version !== 1)
128
+ throw new Error('unsupported business operation version');
129
+ const result = await action(slot);
130
+ if (slot.record) {
131
+ if (slot.record.version !== 1 || slot.record.id !== operationId)
132
+ throw new TypeError('business operation binding');
133
+ this.put(namespace, 'business_operations', operationId, slot.record);
134
+ }
135
+ this.db.exec('RELEASE ' + name);
136
+ return structuredClone(result);
137
+ }
138
+ catch (error) {
139
+ this.db.exec('ROLLBACK TO ' + name);
140
+ this.db.exec('RELEASE ' + name);
141
+ throw error;
142
+ }
143
+ }) };
144
+ }
145
+ /** Same-database business effects only. The callback and operation receipt share the outer transaction. */
146
+ withTransaction(action) {
147
+ return this.run(async () => {
148
+ const context = this.inside.getStore();
149
+ const check = () => { if (!context.active || this.inside.getStore() !== context)
150
+ throw new Error('expired SQLite transaction'); };
151
+ return action({ run: (sql, values = []) => { check(); this.db.prepare(sql).run(...values); }, get: (sql, values = []) => { check(); return this.db.prepare(sql).get(...values); } });
152
+ });
153
+ }
120
154
  conversations(namespace) {
121
155
  const load = (selection) => {
122
156
  const state = { ...this.meta(namespace, 'conversation_meta', () => { const { version, generation } = initialConversationState(); return { version, generation }; }), conversations: {}, bindings: {}, messages: {}, work: [] };
@@ -255,6 +289,52 @@ export class SqliteCompanyDatabase {
255
289
  })),
256
290
  };
257
291
  }
292
+ browserCookies(namespace) {
293
+ return {
294
+ put: (hash, binding) => this.run(async () => { this.put(namespace, 'browser_cookies', hash, binding); }),
295
+ get: hash => this.run(async () => this.one(namespace, 'browser_cookies', hash) ?? null),
296
+ remove: hash => this.run(async () => { this.db.prepare('DELETE FROM records WHERE ns=? AND kind=? AND id=?').run(namespace, 'browser_cookies', hash); }),
297
+ putJoin: (hash, binding) => this.run(async () => { this.put(namespace, 'browser_joins', hash, binding); }),
298
+ authorizeJoin: (hash, principal, at) => this.run(async () => {
299
+ const row = this.one(namespace, 'browser_joins', hash);
300
+ if (!row || row.expiresAt <= at || row.principal)
301
+ return false;
302
+ row.principal = structuredClone(principal);
303
+ this.put(namespace, 'browser_joins', hash, row);
304
+ return true;
305
+ }),
306
+ consumeJoin: hash => this.run(async () => {
307
+ const row = this.one(namespace, 'browser_joins', hash);
308
+ if (row)
309
+ this.db.prepare('DELETE FROM records WHERE ns=? AND kind=? AND id=?').run(namespace, 'browser_joins', hash);
310
+ return row ?? null;
311
+ }),
312
+ };
313
+ }
314
+ sessions(namespace) {
315
+ const load = (clientId, sid) => {
316
+ const meta = this.meta(namespace, 'session_meta', () => ({ version: 1, generation: randomUUID() }));
317
+ if (meta.version !== 1)
318
+ throw new Error('unsupported Session state version');
319
+ return { ...meta, record: this.one(namespace, 'resource_sessions', JSON.stringify([clientId, sid])) ?? null };
320
+ };
321
+ return {
322
+ read: (clientId, sid) => this.run(async () => load(clientId, sid)),
323
+ transaction: (clientId, sid, at, change) => this.run(async () => this.atomic(() => {
324
+ if (!Number.isFinite(at))
325
+ throw new TypeError('Session clock');
326
+ const counts = this.db.prepare("SELECT count(*) AS n,min(json_extract(body,'$.expiresAt')) AS expiry FROM records WHERE ns=? AND kind='resource_sessions' AND json_extract(body,'$.clientId')=? AND json_extract(body,'$.ended')=0 AND json_extract(body,'$.expiresAt')>?").get(namespace, clientId, at);
327
+ const state = { ...load(clientId, sid), activeCount: Number(counts.n), earliestExpiry: counts.expiry === null ? null : Number(counts.expiry) }, generation = state.generation, result = change(state);
328
+ if (result && typeof result.then === 'function')
329
+ throw new TypeError('async Session transaction');
330
+ if (state.version !== 1 || state.generation !== generation || (state.record && (state.record.clientId !== clientId || state.record.sid !== sid)))
331
+ throw new TypeError('Session state binding');
332
+ if (state.record)
333
+ this.put(namespace, 'resource_sessions', JSON.stringify([clientId, sid]), state.record);
334
+ return structuredClone(result);
335
+ })),
336
+ };
337
+ }
258
338
  issuer(namespace) {
259
339
  const kinds = ['sessions', 'sessionKeys', 'grants', 'refreshTokens', 'authorizations', 'codes', 'signIns', 'accessTokens', 'replay'];
260
340
  const load = (selection) => {
@@ -270,7 +350,7 @@ export class SqliteCompanyDatabase {
270
350
  for (const row of this.rows(namespace, kind, where, params))
271
351
  state[kind][row.id] = JSON.parse(row.body);
272
352
  };
273
- if (state.version === 3) {
353
+ if (state.version === 3 || state.version === 4) {
274
354
  for (const kind of kinds)
275
355
  find(kind, '');
276
356
  const before = structuredClone(state);
@@ -281,7 +361,7 @@ export class SqliteCompanyDatabase {
281
361
  for (const kind of kinds)
282
362
  state[kind] = {};
283
363
  }
284
- if (state.version !== 4)
364
+ if (state.version !== 5)
285
365
  throw new Error('unsupported issuer state version');
286
366
  if ((selection.kind === 'token' || selection.kind === 'sign_in') && selection.sessionKey) {
287
367
  get('sessionKeys', selection.sessionKey);
@@ -296,7 +376,7 @@ export class SqliteCompanyDatabase {
296
376
  get('accessTokens', selection.accessHash);
297
377
  for (const key of selection.replayKeys)
298
378
  get('replay', key);
299
- const counts = this.db.prepare("SELECT count(*) AS n, sum(CASE WHEN json_extract(body,'$.clientId')=? THEN 1 ELSE 0 END) AS client FROM records WHERE ns=? AND kind='signIns' AND json_extract(body,'$.status') IN ('pending','approved','verifying') AND json_extract(body,'$.expiresAt')>?").get(selection.clientId ?? '', namespace, selection.at);
379
+ const counts = this.db.prepare("SELECT count(*) AS n, sum(CASE WHEN json_extract(body,'$.clientId')=? THEN 1 ELSE 0 END) AS client FROM records WHERE ns=? AND kind='signIns' AND json_extract(body,'$.status') IN ('pending','approved','verifying') AND (json_extract(body,'$.status')='verifying' OR json_extract(body,'$.expiresAt')>?)").get(selection.clientId ?? '', namespace, selection.at);
300
380
  state.limits = { sessions: 0, earliestSessionExpiry: 0, pending: Number(counts.n), pendingForClient: Number(counts.client ?? 0) };
301
381
  }
302
382
  if (selection.kind === 'principal') {
@@ -378,7 +458,7 @@ export class SqliteCompanyDatabase {
378
458
  throw new TypeError('cleanup limit');
379
459
  return this.run(async () => {
380
460
  const rows = this.db.prepare(`SELECT kind,id FROM records AS retained WHERE ns=? AND
381
- ((kind IN ('authorizations','codes','accessTokens') AND json_extract(body,'$.expiresAt')<=?) OR (kind='signIns' AND json_extract(body,'$.expiresAt')+30000<?) OR (kind='replay' AND CAST(body AS REAL)<?)
461
+ ((kind IN ('authorizations','codes','accessTokens','browser_cookies','browser_joins','resource_sessions') AND json_extract(body,'$.expiresAt')<=?) OR (kind='signIns' AND coalesce(json_extract(body,'$.status'),'')!='verifying' AND json_extract(body,'$.expiresAt')+30000<?) OR (kind='replay' AND CAST(body AS REAL)<?)
382
462
  OR (kind='refreshTokens' AND NOT EXISTS (SELECT 1 FROM records AS lineage WHERE lineage.ns=retained.ns AND lineage.kind='grants' AND lineage.id=json_extract(retained.body,'$') AND json_extract(lineage.body,'$.expiresAt')>? AND json_extract(lineage.body,'$.revoked')=0))) LIMIT ?`).all(namespace, now, now, now, now, limit);
383
463
  for (const row of rows)
384
464
  this.db.prepare('DELETE FROM records WHERE ns=? AND kind=? AND id=?').run(namespace, row.kind, row.id);
@@ -0,0 +1,18 @@
1
+ // Local synthetic acceptance only. Production uses createRequestAuthenticator and sessions.policy.
2
+ import assert from 'node:assert/strict';
3
+ import {mkdtemp,rm} from 'node:fs/promises';
4
+ import {tmpdir} from 'node:os';
5
+ import {join} from 'node:path';
6
+ import {createBusinessOperations,operationApiResponse} from '../dist/index.js';
7
+ import {SqliteCompanyDatabase} from '../dist/sqlite.js';
8
+ const dir=await mkdtemp(join(tmpdir(),'poppy-company-operation-')),db=new SqliteCompanyDatabase(join(dir,'company.db'));
9
+ try{
10
+ const principal={issuer:'https://example.com',clientId:'https://agent.example/client',userId:'synthetic',sessionId:'sid',sessionInstanceId:'instance',sessionGeneration:'generation',sessionVersion:1,tokenExpiresAt:Date.now()+900000,scopes:[]};
11
+ const operations=createBusinessOperations({companyId:principal.issuer,store:db.businessOperations(principal.issuer),metadata:{resolveClient:async()=>({extensions:{operations:{version:'2'}}})},policy:{withCurrent:(_principal,_scopes,action)=>db.run(action)}});
12
+ let effects=0;const input={operationId:'exchange_one',principal,scopes:[],request:{channel:'api',method:'POST',url:'https://example.com/orders/one/exchange',body:Buffer.from('{"sku":"blue"}').toString('base64')},proposal:()=>({summary:'Exchange order one for the blue jacket for $70.',terms:{order_id:'one',sku:'blue',amount:'70.00'},expires_at:new Date(Date.now()+900000).toISOString()})};
13
+ const required=await operations.local(input,async()=>{effects++;return {done:true};});assert.equal(required.kind,'approval_required');assert.equal(effects,0);assert.equal(operationApiResponse(required).status,403);
14
+ // This is an independent synthetic wire fixture, never a real consent mechanism.
15
+ const approval={operation_id:required.operation.operation_id,revision:required.operation.revision};
16
+ const result=await operations.local({...input,approval},async revision=>db.withTransaction(async tx=>{tx.run('INSERT INTO records VALUES (?,?,?,?)',['example','business','exchange_one',JSON.stringify(revision.terms)]);return {done:true};}));
17
+ assert.equal(result.kind,'completed');assert.deepEqual((await operations.local({...input,approval},async()=>{throw Error('duplicate effect');})).result,{done:true});console.log('synthetic operation committed and replayed');
18
+ }finally{db.close();await rm(dir,{recursive:true,force:true});}
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert/strict';
2
- import { createRequestAuthenticator, createConversationService } from '@poppykit/server';
2
+ import { createRequestAuthenticator, createConversationService, createSessionAuthority } from '@poppykit/server';
3
3
  import { createMetadataResolver, createNodeTransport, createReferenceIssuer } from '@poppykit/server/reference-auth';
4
4
  import { SqliteCompanyDatabase } from '@poppykit/server/sqlite';
5
5
 
@@ -8,21 +8,24 @@ const transport = createNodeTransport();
8
8
  try {
9
9
  const issuer = 'https://company.example/issuer';
10
10
  const endpoint = 'https://company.example/conversations';
11
+ const metadata = createMetadataResolver(transport);
11
12
  const auth = createReferenceIssuer({
12
13
  issuer, tokenEndpoint: issuer + '/token', revocationEndpoint: issuer + '/revoke',
13
14
  protectedResources: ['https://company.example'], supportedScopes: [], resources: [endpoint],
14
- metadata: createMetadataResolver(transport), store: database.issuer(issuer),
15
+ metadata, store: database.issuer(issuer),
15
16
  coordinator: database, now: Date.now,
16
17
  });
18
+ const sessions = createSessionAuthority({companyId:issuer,metadata,store:database.sessions(issuer),
19
+ coordinator:database,accountPolicy:auth.authority.policy,now:Date.now});
17
20
  const conversations = createConversationService({
18
21
  historyScopes: ['orders:read'],
19
- endpoint, store: database.conversations(issuer), policy: auth.authority.policy,
20
- authenticator: createRequestAuthenticator({ authority: auth.authority,
22
+ endpoint, store: database.conversations(issuer), policy: sessions.policy,
23
+ authenticator: createRequestAuthenticator({ sessions, authority: auth.authority,
21
24
  endpoints: [{ origin: 'https://company.example', pathPrefix: '/conversations', resource: endpoint }],
22
25
  now: Date.now }), clock: { now: Date.now },
23
26
  });
24
27
  // Your HTTPS framework routes trusted public Requests to this handler.
25
- assert.equal((await conversations.handle(new Request(endpoint, {method:'POST', headers:{'content-type':'application/json'}, body:'{}'}))).status, 401);
28
+ assert.equal((await conversations.handle(new Request(endpoint, {method:'POST', headers:{'content-type':'application/json'}, body:'{}'}))).status, 400);
26
29
  console.log('Reference issuer and conversation service configured; anonymous request rejected.');
27
30
  } finally {
28
31
  transport.close?.();
@@ -0,0 +1,77 @@
1
+ # Explicit retirement of legacy issuer rows
2
+
3
+ This is an operator-run record migration, separate from the automatic v3/v4 → v5
4
+ record enrichment. It is not called by constructors, requests, cleanup(), or SQL
5
+ schema installation. No existing SQL migration is rewritten. No live data has
6
+ been migrated or deleted by preparing this plan.
7
+
8
+ Stop every old and new writer, back up the database and its matching protected
9
+ cipher/vault keys, and rehearse on a disposable copy. Select one exact namespace.
10
+ Complete the v5 enrichment first: it backfills access-token client/account identity
11
+ only from proven associations. Unknown/conflicting associations require manual
12
+ reconciliation. Do not guess them from a key, User ID, or a shared host.
13
+
14
+ Take an exclusive write transaction: BEGIN IMMEDIATE for SQLite; BEGIN followed
15
+ by LOCK TABLE poppy_server.records IN EXCLUSIVE MODE for PostgreSQL. Keep the
16
+ transaction through selection, validation and deletion. Confirm the selected
17
+ issuer_meta record is version 5. Abort on another version or unresolved work.
18
+
19
+ ## Prepare the deletion list
20
+
21
+ Use the existing bounded cleanup for expired access tokens, codes, authorizations,
22
+ browser cookies/join codes and replay records first. Preserve mediated verifying
23
+ records, active refresh-token ancestry, encrypted callback delivery references,
24
+ grants, conversations, work, receipts and events. A timeout is not proof of failure.
25
+
26
+ Then inspect legacy sessions/sessionKeys with these rules, in this order:
27
+
28
+ 1. A key mapping must be a JSON string pointing to an absent or expired legacy
29
+ Session. Retain it if any record in the namespace refers to its key through
30
+ sessionKey or jkt, or to its target through sessionId, principal.sessionId or
31
+ authority.principal.sessionId. This includes pending/uncertain sign-ins,
32
+ authorization/code records, browser snapshots and accepted conversation/work
33
+ authority. Retain malformed or conflicting mappings for reconciliation.
34
+ 2. After deleting only approved, unreferenced mappings, consider an expired legacy
35
+ Session. Retain it while any mapping points to it or any record has one of the
36
+ Session references above. An active Session is never a candidate. Current
37
+ resource_sessions are a different record kind and are excluded.
38
+ 3. Do not delete grants or live refreshTokens as part of Session retirement. The
39
+ ordinary refresh-token expiry/revocation cleanup has its own lineage checks.
40
+
41
+ The cross-reference scan is deliberately conservative: even completed history can
42
+ retain a reference. Its removal requires a separate, explicit retention decision.
43
+ Never remove a reference merely to make its target eligible for deletion.
44
+
45
+ For SQLite, read references with json_extract(body,'$.sessionKey'), '$.jkt',
46
+ '$.sessionId', '$.principal.sessionId' and '$.authority.principal.sessionId'.
47
+ Read mapping targets with json_extract(body,'$'), checking json_type(body)='text'.
48
+ For PostgreSQL use body::jsonb->>'sessionKey', body::jsonb->>'jkt',
49
+ body::jsonb->>'sessionId', body::jsonb#>>'{principal,sessionId}' and
50
+ body::jsonb#>>'{authority,principal,sessionId}'; mapping targets use
51
+ body::jsonb#>>'{}' with jsonb_typeof(body::jsonb)='string'. Every lookup includes ns.
52
+
53
+ ## Apply only the reviewed list
54
+
55
+ Record the approved kind, id and exact original body privately. Recheck all
56
+ references under the same write lock immediately before each removal. Use bound
57
+ parameters and compare the original body, for example:
58
+
59
+ ```sql
60
+ -- SQLite, inside the exclusive transaction and only after the checks above:
61
+ DELETE FROM records WHERE ns=?1 AND kind=?2 AND id=?3 AND body=?4;
62
+ -- PostgreSQL, inside the locked transaction:
63
+ DELETE FROM poppy_server.records WHERE ns=$1 AND kind=$2 AND id=$3 AND body=$4;
64
+ ```
65
+
66
+ Allow only kind=sessionKeys first, then kind=sessions; require exactly one affected
67
+ row for each approved removal. Abort and roll back if a body, reference, version
68
+ or count differs. Work in bounded batches of at most 1000 records. Verify that
69
+ credential, pending/uncertain, recovery and conversation records are unchanged
70
+ before committing. Restart only compatible writers. Keep the backup until the
71
+ production migration has independent acceptance; do not downgrade old writers
72
+ onto the enriched records.
73
+
74
+ The SDK's existing migration tests prove record preservation across SQLite and
75
+ PostgreSQL. Execution of this optional retirement plan, and deployment-specific
76
+ retention choices, need the operator's disposable rehearsal and later production
77
+ authorization. Automatic legacy-row deletion was intentionally not added.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poppykit/server",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "description": "Personal Agent Protocol company SDK",
6
6
  "repository": {
@@ -42,7 +42,9 @@
42
42
  "RELEASING.md",
43
43
  "examples",
44
44
  "LICENSE",
45
- "migrations"
45
+ "migrations",
46
+ "CONFORMANCE.md",
47
+ "OPERATIONS.md"
46
48
  ],
47
49
  "exports": {
48
50
  ".": {
@@ -67,6 +69,7 @@
67
69
  }
68
70
  },
69
71
  "dependencies": {
72
+ "@agentsig/structured-fields": "0.1.1",
70
73
  "jose": "6.2.12"
71
74
  },
72
75
  "license": "MIT",