@poppykit/server 0.3.1 → 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 (57) hide show
  1. package/API.md +11 -3
  2. package/AUTH.md +120 -95
  3. package/CHANGELOG.md +18 -0
  4. package/CONFORMANCE.md +73 -0
  5. package/CONTRIBUTING.md +13 -10
  6. package/DELIVERY.md +21 -1
  7. package/INTEGRATIONS.md +72 -69
  8. package/OPERATIONS.md +142 -0
  9. package/README.md +14 -17
  10. package/RELEASING.md +2 -0
  11. package/SECURITY.md +2 -0
  12. package/STORAGE.md +57 -2
  13. package/dist/adapter-contract.d.ts +18 -0
  14. package/dist/adapter-contract.js +90 -11
  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 -13
  22. package/dist/browser.js +80 -46
  23. package/dist/conversation/service.d.ts +18 -8
  24. package/dist/conversation/service.js +60 -28
  25. package/dist/feedback.d.ts +1 -1
  26. package/dist/feedback.js +5 -5
  27. package/dist/index.d.ts +6 -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 +51 -22
  32. package/dist/network/discovery.js +159 -69
  33. package/dist/network/redirect.d.ts +3 -0
  34. package/dist/network/redirect.js +38 -0
  35. package/dist/network/tasks.d.ts +23 -0
  36. package/dist/network/tasks.js +45 -0
  37. package/dist/operations.d.ts +115 -0
  38. package/dist/operations.js +212 -0
  39. package/dist/postgres.d.ts +6 -0
  40. package/dist/postgres.js +92 -6
  41. package/dist/protocol/operations.d.ts +51 -0
  42. package/dist/protocol/operations.js +98 -0
  43. package/dist/protocol/session.d.ts +28 -0
  44. package/dist/protocol/session.js +93 -0
  45. package/dist/protocol/sse.d.ts +1 -0
  46. package/dist/protocol/sse.js +1 -1
  47. package/dist/protocol/wire.d.ts +20 -15
  48. package/dist/protocol/wire.js +70 -22
  49. package/dist/reference-auth/index.d.ts +17 -12
  50. package/dist/reference-auth/index.js +252 -386
  51. package/dist/reference-auth/store.d.ts +2 -1
  52. package/dist/sqlite.d.ts +11 -0
  53. package/dist/sqlite.js +104 -6
  54. package/examples/operations.mjs +18 -0
  55. package/examples/quickstart.mjs +10 -6
  56. package/migrations/RETIREMENT.md +77 -0
  57. package/package.json +5 -2
@@ -6,7 +6,7 @@ export type IssuerSelection = {
6
6
  } | {
7
7
  kind: 'token';
8
8
  clientId: string;
9
- sessionId?: string;
9
+ sessionKey: string;
10
10
  codeHash?: string;
11
11
  refreshHash?: string;
12
12
  replayKeys: string[];
@@ -43,6 +43,7 @@ export type IssuerSelection = {
43
43
  ids: string[];
44
44
  clientId?: string;
45
45
  sessionId?: string;
46
+ sessionKey?: string;
46
47
  accessHash?: string;
47
48
  replayKeys: string[];
48
49
  at: number;
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,9 +1,10 @@
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';
4
5
  import { setTimeout as sleep } from 'node:timers/promises';
5
6
  import { initialConversationState } from './conversation/service.js';
6
- import { initialIssuerState } from './reference-auth/index.js';
7
+ import { initialIssuerState, upgradeIssuerState } from './reference-auth/index.js';
7
8
  import { clientRevisionKey, accountRevisionKey } from './reference-auth/store.js';
8
9
  function json(value) {
9
10
  return JSON.stringify(value, (_key, item) => {
@@ -117,9 +118,44 @@ 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: [] };
157
+ if (state.version !== 2)
158
+ throw new Error('conversation reset required for draft 0.3');
123
159
  let id = selection.conversationId;
124
160
  if (selection.messageKey) {
125
161
  const message = this.one(namespace, 'messages', selection.messageKey);
@@ -253,10 +289,56 @@ export class SqliteCompanyDatabase {
253
289
  })),
254
290
  };
255
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
+ }
256
338
  issuer(namespace) {
257
- const kinds = ['sessions', 'grants', 'refreshTokens', 'authorizations', 'codes', 'signIns', 'accessTokens', 'replay'];
339
+ const kinds = ['sessions', 'sessionKeys', 'grants', 'refreshTokens', 'authorizations', 'codes', 'signIns', 'accessTokens', 'replay'];
258
340
  const load = (selection) => {
259
- const state = { ...this.meta(namespace, 'issuer_meta', () => { const { version, generation, nonces } = initialIssuerState(); return { version, generation, nonces }; }), sessions: {}, grants: {}, refreshTokens: {}, authorizations: {}, codes: {}, signIns: {}, accessTokens: {}, replay: {} };
341
+ const state = { ...this.meta(namespace, 'issuer_meta', () => { const { version, generation, nonces } = initialIssuerState(); return { version, generation, nonces }; }), sessions: {}, sessionKeys: {}, grants: {}, refreshTokens: {}, authorizations: {}, codes: {}, signIns: {}, accessTokens: {}, replay: {} };
260
342
  const get = (kind, id) => {
261
343
  if (!id)
262
344
  return;
@@ -268,6 +350,23 @@ export class SqliteCompanyDatabase {
268
350
  for (const row of this.rows(namespace, kind, where, params))
269
351
  state[kind][row.id] = JSON.parse(row.body);
270
352
  };
353
+ if (state.version === 3 || state.version === 4) {
354
+ for (const kind of kinds)
355
+ find(kind, '');
356
+ const before = structuredClone(state);
357
+ upgradeIssuerState(state);
358
+ for (const kind of kinds)
359
+ this.save(namespace, kind, before[kind], state[kind]);
360
+ this.put(namespace, 'issuer_meta', 'meta', { version: state.version, generation: state.generation, nonces: state.nonces });
361
+ for (const kind of kinds)
362
+ state[kind] = {};
363
+ }
364
+ if (state.version !== 5)
365
+ throw new Error('unsupported issuer state version');
366
+ if ((selection.kind === 'token' || selection.kind === 'sign_in') && selection.sessionKey) {
367
+ get('sessionKeys', selection.sessionKey);
368
+ get('sessions', state.sessionKeys[selection.sessionKey]);
369
+ }
271
370
  if (selection.kind === 'access')
272
371
  get('accessTokens', selection.hash);
273
372
  if (selection.kind === 'sign_in') {
@@ -277,7 +376,7 @@ export class SqliteCompanyDatabase {
277
376
  get('accessTokens', selection.accessHash);
278
377
  for (const key of selection.replayKeys)
279
378
  get('replay', key);
280
- 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);
281
380
  state.limits = { sessions: 0, earliestSessionExpiry: 0, pending: Number(counts.n), pendingForClient: Number(counts.client ?? 0) };
282
381
  }
283
382
  if (selection.kind === 'principal') {
@@ -293,7 +392,6 @@ export class SqliteCompanyDatabase {
293
392
  get('signIns', selection.revocationKey);
294
393
  }
295
394
  if (selection.kind === 'token') {
296
- get('sessions', selection.sessionId);
297
395
  get('codes', selection.codeHash);
298
396
  get('refreshTokens', selection.refreshHash);
299
397
  for (const key of selection.replayKeys)
@@ -360,7 +458,7 @@ export class SqliteCompanyDatabase {
360
458
  throw new TypeError('cleanup limit');
361
459
  return this.run(async () => {
362
460
  const rows = this.db.prepare(`SELECT kind,id FROM records AS retained WHERE ns=? AND
363
- ((kind IN ('authorizations','codes','accessTokens') AND json_extract(body,'$.expiresAt')<=?) OR (kind='signIns' AND json_extract(body,'$.expiresAt')+30000<?) OR (kind='replay' AND CAST(body AS REAL)<?)
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)<?)
364
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);
365
463
  for (const row of rows)
366
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,20 +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
- poppyDomains: ['company.example'], directScopes: [], resources: [endpoint],
14
- metadata: createMetadataResolver(transport), store: database.issuer(issuer),
14
+ protectedResources: ['https://company.example'], supportedScopes: [], resources: [endpoint],
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
- endpoint, store: database.conversations(issuer), policy: auth.authority.policy,
19
- authenticator: createRequestAuthenticator({ authority: auth.authority,
21
+ historyScopes: ['orders:read'],
22
+ endpoint, store: database.conversations(issuer), policy: sessions.policy,
23
+ authenticator: createRequestAuthenticator({ sessions, authority: auth.authority,
20
24
  endpoints: [{ origin: 'https://company.example', pathPrefix: '/conversations', resource: endpoint }],
21
25
  now: Date.now }), clock: { now: Date.now },
22
26
  });
23
27
  // Your HTTPS framework routes trusted public Requests to this handler.
24
- 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);
25
29
  console.log('Reference issuer and conversation service configured; anonymous request rejected.');
26
30
  } finally {
27
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.3.1",
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",