@poppykit/server 0.0.0-stage → 0.1.1

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 (51) hide show
  1. package/API.md +21 -0
  2. package/AUTH.md +60 -0
  3. package/CHANGELOG.md +18 -0
  4. package/CONTRIBUTING.md +38 -0
  5. package/DELIVERY.md +77 -0
  6. package/INTEGRATIONS.md +84 -0
  7. package/LICENSE +21 -0
  8. package/README.md +67 -2
  9. package/RELEASING.md +91 -0
  10. package/SECURITY.md +13 -0
  11. package/STORAGE.md +112 -0
  12. package/dist/adapter-contract.d.ts +12 -0
  13. package/dist/adapter-contract.js +140 -0
  14. package/dist/auth/authenticator.d.ts +6 -0
  15. package/dist/auth/authenticator.js +51 -0
  16. package/dist/auth/authority.d.ts +55 -0
  17. package/dist/auth/authority.js +16 -0
  18. package/dist/auth/dpop.d.ts +15 -0
  19. package/dist/auth/dpop.js +49 -0
  20. package/dist/browser.d.ts +44 -0
  21. package/dist/browser.js +110 -0
  22. package/dist/conversation/service.d.ts +126 -0
  23. package/dist/conversation/service.js +754 -0
  24. package/dist/conversation/store.d.ts +26 -0
  25. package/dist/conversation/store.js +14 -0
  26. package/dist/feedback.d.ts +13 -0
  27. package/dist/feedback.js +44 -0
  28. package/dist/index.d.ts +11 -0
  29. package/dist/index.js +7 -0
  30. package/dist/mcp.d.ts +7 -0
  31. package/dist/mcp.js +14 -0
  32. package/dist/network/discovery.d.ts +101 -0
  33. package/dist/network/discovery.js +276 -0
  34. package/dist/network/transport.d.ts +31 -0
  35. package/dist/network/transport.js +305 -0
  36. package/dist/protocol/sse.d.ts +15 -0
  37. package/dist/protocol/sse.js +90 -0
  38. package/dist/protocol/wire.d.ts +128 -0
  39. package/dist/protocol/wire.js +278 -0
  40. package/dist/reference-auth/index.d.ts +209 -0
  41. package/dist/reference-auth/index.js +999 -0
  42. package/dist/reference-auth/store.d.ts +53 -0
  43. package/dist/reference-auth/store.js +2 -0
  44. package/dist/sqlite-init.d.ts +2 -0
  45. package/dist/sqlite-init.js +17 -0
  46. package/dist/sqlite.d.ts +27 -0
  47. package/dist/sqlite.js +369 -0
  48. package/dist/store.d.ts +1 -0
  49. package/dist/store.js +1 -0
  50. package/examples/quickstart.mjs +29 -0
  51. package/package.json +67 -4
@@ -0,0 +1,53 @@
1
+ import type { IssuerState } from './index.js';
2
+ export declare const clientRevisionKey: (clientId: string) => string;
3
+ export declare const accountRevisionKey: (clientId: string, accountId: string) => string;
4
+ export type IssuerSelection = {
5
+ kind: 'metadata';
6
+ } | {
7
+ kind: 'token';
8
+ clientId: string;
9
+ sessionId?: string;
10
+ codeHash?: string;
11
+ refreshHash?: string;
12
+ replayKeys: string[];
13
+ at: number;
14
+ } | {
15
+ kind: 'revoke';
16
+ refreshHash: string;
17
+ replayKey: string;
18
+ } | {
19
+ kind: 'principal';
20
+ sessionId: string;
21
+ grantId?: string;
22
+ } | {
23
+ kind: 'browser';
24
+ sessionId: string;
25
+ replayKey?: string;
26
+ } | {
27
+ kind: 'access';
28
+ hash: string;
29
+ } | {
30
+ kind: 'authorize';
31
+ clientId: string;
32
+ at: number;
33
+ } | {
34
+ kind: 'authorization';
35
+ id: string;
36
+ revocationKey?: string;
37
+ } | {
38
+ kind: 'disconnect';
39
+ clientId: string;
40
+ accountId: string;
41
+ } | {
42
+ kind: 'sign_in';
43
+ ids: string[];
44
+ clientId?: string;
45
+ sessionId?: string;
46
+ accessHash?: string;
47
+ replayKeys: string[];
48
+ at: number;
49
+ };
50
+ export interface IssuerStore {
51
+ read(selection: IssuerSelection): Promise<IssuerState>;
52
+ transaction<T>(selection: IssuerSelection, change: (draft: IssuerState) => T): Promise<T>;
53
+ }
@@ -0,0 +1,2 @@
1
+ export const clientRevisionKey = (clientId) => `revision ${clientId}`;
2
+ export const accountRevisionKey = (clientId, accountId) => `revoked ${JSON.stringify([clientId, accountId])}`;
@@ -0,0 +1,2 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ export declare function initializeSqlite(db: DatabaseSync, initialize: () => void): void;
@@ -0,0 +1,17 @@
1
+ // SQLite may return BUSY immediately when two processes first enable WAL, despite busy_timeout.
2
+ export function initializeSqlite(db, initialize) {
3
+ const deadline = Date.now() + 5000;
4
+ for (;;) {
5
+ try {
6
+ initialize();
7
+ return;
8
+ }
9
+ catch (error) {
10
+ if (![5, 6].includes(error.errcode ?? 0) || Date.now() >= deadline) {
11
+ db.close();
12
+ throw error;
13
+ }
14
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,27 @@
1
+ import type { ConversationStore } from './conversation/store.js';
2
+ import type { IssuerStore } from './reference-auth/store.js';
3
+ /** Share this instance between issuer and conversations so authorization and mutation commit together. */
4
+ export declare class SqliteCompanyDatabase {
5
+ private readonly db;
6
+ private readonly inside;
7
+ private queue;
8
+ private savepoint;
9
+ readonly metrics: {
10
+ rowsRead: number;
11
+ rowsWritten: number;
12
+ };
13
+ constructor(path: string);
14
+ close(): void;
15
+ /** Required SQLite writer serialization, not an application-wide lock or network-call boundary. */
16
+ run<T>(action: () => Promise<T>): Promise<T>;
17
+ private atomic;
18
+ private rows;
19
+ private one;
20
+ private put;
21
+ private save;
22
+ private meta;
23
+ conversations(namespace: string): ConversationStore;
24
+ issuer(namespace: string): IssuerStore;
25
+ /** Bounded maintenance; never deletes message receipts, events, grants or work intent. */
26
+ cleanup(namespace: string, now: number, limit?: number): Promise<number>;
27
+ }
package/dist/sqlite.js ADDED
@@ -0,0 +1,369 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { initializeSqlite } from './sqlite-init.js';
3
+ import { AsyncLocalStorage } from 'node:async_hooks';
4
+ import { setTimeout as sleep } from 'node:timers/promises';
5
+ import { initialConversationState } from './conversation/service.js';
6
+ import { initialIssuerState } from './reference-auth/index.js';
7
+ import { clientRevisionKey, accountRevisionKey } from './reference-auth/store.js';
8
+ function json(value) {
9
+ return JSON.stringify(value, (_key, item) => {
10
+ if (item === undefined || typeof item === 'function' || typeof item === 'bigint' || (typeof item === 'number' && !Number.isFinite(item)))
11
+ throw new TypeError('non-JSON record');
12
+ return item;
13
+ });
14
+ }
15
+ /** Share this instance between issuer and conversations so authorization and mutation commit together. */
16
+ export class SqliteCompanyDatabase {
17
+ db;
18
+ inside = new AsyncLocalStorage();
19
+ queue = Promise.resolve();
20
+ savepoint = 0;
21
+ metrics = { rowsRead: 0, rowsWritten: 0 };
22
+ constructor(path) {
23
+ this.db = new DatabaseSync(path);
24
+ initializeSqlite(this.db, () => this.db.exec(`PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;
25
+ CREATE TABLE IF NOT EXISTS records (ns TEXT NOT NULL, kind TEXT NOT NULL, id TEXT NOT NULL, body TEXT NOT NULL, PRIMARY KEY(ns,kind,id));
26
+ CREATE INDEX IF NOT EXISTS by_conversation ON records(ns,kind,json_extract(body,'$.conversationId'));
27
+ CREATE INDEX IF NOT EXISTS by_binding ON records(ns,kind,json_extract(body,'$.bindingId'));
28
+ CREATE INDEX IF NOT EXISTS by_client ON records(ns,kind,json_extract(body,'$.clientId'),json_extract(body,'$.expiresAt'));
29
+ CREATE INDEX IF NOT EXISTS by_account ON records(ns,kind,json_extract(body,'$.clientId'),json_extract(body,'$.accountId'));
30
+ CREATE INDEX IF NOT EXISTS by_grant ON records(ns,kind,json_extract(body,'$.grantId'));
31
+ CREATE INDEX IF NOT EXISTS by_phase ON records(ns,kind,json_extract(body,'$.phase'));
32
+ CREATE INDEX IF NOT EXISTS by_expiry ON records(ns,kind,json_extract(body,'$.expiresAt'));
33
+ CREATE INDEX IF NOT EXISTS replay_expiry ON records(ns,kind,CAST(body AS REAL));
34
+ CREATE TABLE IF NOT EXISTS events (ns TEXT NOT NULL, conversation TEXT NOT NULL, seq INTEGER NOT NULL, body TEXT NOT NULL, PRIMARY KEY(ns,conversation,seq));
35
+ CREATE INDEX IF NOT EXISTS last_user_event ON events(ns,conversation,json_extract(body,'$.type'),json_extract(body,'$.message.role'),seq);
36
+ CREATE TABLE IF NOT EXISTS event_cursors (ns TEXT NOT NULL, conversation TEXT NOT NULL, id TEXT NOT NULL, seq INTEGER NOT NULL, PRIMARY KEY(ns,conversation,id));
37
+ CREATE INDEX IF NOT EXISTS cursor_seq ON event_cursors(ns,conversation,seq);
38
+ INSERT OR IGNORE INTO event_cursors SELECT ns,conversation,json_extract(body,'$.id'),seq FROM events;
39
+ PRAGMA busy_timeout=0;`));
40
+ }
41
+ close() { this.db.close(); }
42
+ /** Required SQLite writer serialization, not an application-wide lock or network-call boundary. */
43
+ run(action) {
44
+ if (this.inside.getStore()?.active)
45
+ return action();
46
+ const run = this.queue.then(async () => {
47
+ const deadline = Date.now() + 5000;
48
+ for (;;) {
49
+ try {
50
+ this.db.exec('BEGIN IMMEDIATE');
51
+ break;
52
+ }
53
+ catch (error) {
54
+ if (!(error instanceof Error && /locked|busy/i.test(error.message)) || Date.now() >= deadline)
55
+ throw error;
56
+ await sleep(10);
57
+ }
58
+ }
59
+ const context = { active: true };
60
+ try {
61
+ const result = await this.inside.run(context, action);
62
+ this.db.exec('COMMIT');
63
+ return result;
64
+ }
65
+ catch (error) {
66
+ this.db.exec('ROLLBACK');
67
+ throw error;
68
+ }
69
+ finally {
70
+ context.active = false;
71
+ }
72
+ });
73
+ this.queue = run.catch(() => { });
74
+ return run;
75
+ }
76
+ atomic(action) {
77
+ const name = `nested_${++this.savepoint}`;
78
+ this.db.exec(`SAVEPOINT ${name}`);
79
+ try {
80
+ const result = action();
81
+ this.db.exec(`RELEASE ${name}`);
82
+ return result;
83
+ }
84
+ catch (error) {
85
+ this.db.exec(`ROLLBACK TO ${name}; RELEASE ${name}`);
86
+ throw error;
87
+ }
88
+ }
89
+ rows(ns, kind, where = '', params = []) {
90
+ const rows = this.db.prepare(`SELECT id,body FROM records WHERE ns=? AND kind=? ${where}`).all(ns, kind, ...params);
91
+ this.metrics.rowsRead += rows.length;
92
+ return rows;
93
+ }
94
+ one(ns, kind, id) {
95
+ const row = this.rows(ns, kind, 'AND id=?', [id])[0];
96
+ return row ? JSON.parse(row.body) : undefined;
97
+ }
98
+ put(ns, kind, id, value) {
99
+ this.db.prepare('INSERT INTO records VALUES (?,?,?,?) ON CONFLICT(ns,kind,id) DO UPDATE SET body=excluded.body').run(ns, kind, id, json(value));
100
+ this.metrics.rowsWritten++;
101
+ }
102
+ save(ns, kind, before, after) {
103
+ for (const id of Object.keys(before))
104
+ if (!Object.hasOwn(after, id)) {
105
+ this.db.prepare('DELETE FROM records WHERE ns=? AND kind=? AND id=?').run(ns, kind, id);
106
+ this.metrics.rowsWritten++;
107
+ }
108
+ for (const [id, value] of Object.entries(after))
109
+ if (!Object.hasOwn(before, id) || json(before[id]) !== json(value))
110
+ this.put(ns, kind, id, value);
111
+ }
112
+ meta(ns, kind, initial) {
113
+ const found = this.one(ns, kind, 'meta');
114
+ if (found)
115
+ return found;
116
+ const value = initial();
117
+ this.put(ns, kind, 'meta', value);
118
+ return value;
119
+ }
120
+ conversations(namespace) {
121
+ const load = (selection) => {
122
+ const state = { ...this.meta(namespace, 'conversation_meta', () => { const { version, generation } = initialConversationState(); return { version, generation }; }), conversations: {}, bindings: {}, messages: {}, work: [] };
123
+ let id = selection.conversationId;
124
+ if (selection.messageKey) {
125
+ const message = this.one(namespace, 'messages', selection.messageKey);
126
+ if (message) {
127
+ state.messages[selection.messageKey] = message;
128
+ id = message.conversationId;
129
+ }
130
+ }
131
+ if (selection.workId)
132
+ id = this.one(namespace, 'work', selection.workId)?.conversationId;
133
+ if (selection.claimAt !== undefined) {
134
+ const row = this.db.prepare(`SELECT w.body FROM records w JOIN records c ON c.ns=w.ns AND c.kind='conversations' AND c.id=json_extract(w.body,'$.conversationId')
135
+ WHERE w.ns=? AND w.kind='work' AND json_extract(w.body,'$.phase') IN ('pending','claimed','held')
136
+ AND json_extract(c.body,'$.childId') IS NULL AND (
137
+ (json_extract(c.body,'$.responder')='human' AND coalesce(json_extract(c.body,'$.humanUntil'),0)<=?) OR
138
+ (json_extract(c.body,'$.responder')!='human' AND json_extract(c.body,'$.status')!='queued' AND json_extract(w.body,'$.phase') IN ('pending','claimed')
139
+ AND (json_extract(w.body,'$.phase')='pending' OR coalesce(json_extract(w.body,'$.leaseUntil'),0)<=?)
140
+ AND NOT EXISTS (SELECT 1 FROM records earlier WHERE earlier.ns=w.ns AND earlier.kind='work' AND earlier.rowid<w.rowid
141
+ AND json_extract(earlier.body,'$.conversationId')=json_extract(w.body,'$.conversationId') AND json_extract(earlier.body,'$.phase') IN ('pending','claimed'))))
142
+ ORDER BY w.rowid LIMIT 1`).get(namespace, selection.claimAt, selection.claimAt);
143
+ if (row) {
144
+ this.metrics.rowsRead++;
145
+ id = JSON.parse(row.body).conversationId;
146
+ }
147
+ }
148
+ const target = id ? this.one(namespace, 'conversations', id) : undefined;
149
+ if (!target)
150
+ return state;
151
+ if (selection.cursorCheck !== undefined) {
152
+ const found = this.db.prepare('SELECT seq FROM event_cursors WHERE ns=? AND conversation=? AND id=?').get(namespace, target.id, selection.cursorCheck);
153
+ if (!found)
154
+ state.cursorStatus = 'invalid';
155
+ else if (!this.db.prepare('SELECT 1 FROM events WHERE ns=? AND conversation=? AND seq=?').get(namespace, target.id, Number(found.seq)))
156
+ state.cursorStatus = 'expired';
157
+ }
158
+ for (const row of this.rows(namespace, 'conversations', selection.events ? 'AND id=?' : "AND json_extract(body,'$.bindingId')=?", [selection.events ? target.id : target.bindingId])) {
159
+ const conversation = JSON.parse(row.body);
160
+ let from = -1;
161
+ if (selection.events?.cursor !== undefined) {
162
+ const found = this.db.prepare('SELECT seq FROM event_cursors WHERE ns=? AND conversation=? AND id=?').get(namespace, row.id, selection.events.cursor);
163
+ if (!found)
164
+ conversation.eventWindow = { invalid: true, hasMore: false };
165
+ else {
166
+ from = Number(found.seq);
167
+ if (!this.db.prepare('SELECT 1 FROM events WHERE ns=? AND conversation=? AND seq=?').get(namespace, row.id, from))
168
+ conversation.eventWindow = { expired: true, hasMore: false };
169
+ }
170
+ }
171
+ const events = [];
172
+ let hasMore = false;
173
+ if (selection.events) {
174
+ let bytes = 0;
175
+ for (const event of this.db.prepare('SELECT body FROM events WHERE ns=? AND conversation=? AND seq>? ORDER BY seq LIMIT ?').iterate(namespace, row.id, from, selection.events.limit + 1)) {
176
+ this.metrics.rowsRead++;
177
+ const size = Buffer.byteLength(event.body) + 1;
178
+ if (events.length >= selection.events.limit || bytes + size > (selection.events.maxBytes ?? 1048576)) {
179
+ hasMore = true;
180
+ break;
181
+ }
182
+ events.push(event);
183
+ bytes += size;
184
+ }
185
+ conversation.eventWindow = { ...conversation.eventWindow, hasMore };
186
+ }
187
+ else if (!selection.append)
188
+ events.push(...this.db.prepare('SELECT body FROM events WHERE ns=? AND conversation=? ORDER BY seq').all(namespace, row.id));
189
+ if (!selection.events)
190
+ this.metrics.rowsRead += events.length;
191
+ if (selection.append && !conversation.lastUserMessage) {
192
+ const last = this.db.prepare("SELECT body FROM events WHERE ns=? AND conversation=? AND json_extract(body,'$.type')='message' AND json_extract(body,'$.message.role')='user' ORDER BY seq DESC LIMIT 1").get(namespace, row.id);
193
+ if (last) {
194
+ this.metrics.rowsRead++;
195
+ conversation.lastUserMessage = JSON.parse(last.body).message;
196
+ }
197
+ }
198
+ conversation.events = events.map(event => JSON.parse(event.body));
199
+ state.conversations[row.id] = conversation;
200
+ if (!selection.events)
201
+ state.work.push(...this.rows(namespace, 'work', "AND json_extract(body,'$.conversationId')=? ORDER BY rowid", [row.id]).map(work => JSON.parse(work.body)));
202
+ }
203
+ const binding = this.one(namespace, 'bindings', target.bindingId);
204
+ if (binding)
205
+ state.bindings[target.bindingId] = binding;
206
+ return state;
207
+ };
208
+ return {
209
+ pruneEvents: (conversationId, before, limit = 100) => {
210
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000 || !Number.isFinite(before))
211
+ throw new TypeError('retention limit');
212
+ return this.run(async () => {
213
+ const active = this.db.prepare("SELECT 1 FROM records WHERE ns=? AND kind='work' AND json_extract(body,'$.conversationId')=? AND json_extract(body,'$.phase') IN ('held','pending','claimed') LIMIT 1").get(namespace, conversationId);
214
+ if (active)
215
+ return 0;
216
+ const removed = this.db.prepare("DELETE FROM events WHERE ns=? AND conversation=? AND seq IN (SELECT seq FROM events WHERE ns=? AND conversation=? AND json_extract(body,'$.created_at')<? ORDER BY seq LIMIT ?)").run(namespace, conversationId, namespace, conversationId, new Date(before).toISOString(), limit);
217
+ return Number(removed.changes);
218
+ });
219
+ },
220
+ read: selection => this.run(async () => load(selection)),
221
+ transaction: (selection, change) => this.run(async () => this.atomic(() => {
222
+ const state = load(selection), before = structuredClone(state);
223
+ const result = change(state);
224
+ if (result && typeof result.then === 'function')
225
+ throw new TypeError('async conversation transaction');
226
+ if (selection.events) {
227
+ if (json(state) !== json(before))
228
+ throw new TypeError('event windows are read-only');
229
+ return structuredClone(result);
230
+ }
231
+ if (state.version !== before.version || state.generation !== before.generation || Object.keys(before.conversations).some(id => !Object.hasOwn(state.conversations, id)))
232
+ throw new TypeError('use an explicit offline reset');
233
+ for (const [id, conversation] of Object.entries(state.conversations)) {
234
+ const oldEvents = before.conversations[id]?.events ?? [];
235
+ if (conversation.events.length < oldEvents.length || oldEvents.some((event, i) => json(event) !== json(conversation.events[i])))
236
+ throw new TypeError('events must be append-only');
237
+ const last = Number(this.db.prepare('SELECT coalesce(max(seq),-1) AS seq FROM event_cursors WHERE ns=? AND conversation=?').get(namespace, id).seq);
238
+ for (let i = oldEvents.length; i < conversation.events.length; i++) {
239
+ const seq = last + i - oldEvents.length + 1, event = conversation.events[i];
240
+ this.db.prepare('INSERT INTO events VALUES (?,?,?,?)').run(namespace, id, seq, json(event));
241
+ this.db.prepare('INSERT INTO event_cursors VALUES (?,?,?,?)').run(namespace, id, event.id, seq);
242
+ this.metrics.rowsWritten += 2;
243
+ }
244
+ const { events: _events, ...record } = conversation;
245
+ const { events: _oldEvents, ...old } = before.conversations[id] ?? {};
246
+ if (json(record) !== json(old))
247
+ this.put(namespace, 'conversations', id, record);
248
+ }
249
+ this.save(namespace, 'bindings', before.bindings, state.bindings);
250
+ this.save(namespace, 'messages', before.messages, state.messages);
251
+ this.save(namespace, 'work', Object.fromEntries(before.work.map(row => [row.id, row])), Object.fromEntries(state.work.map(row => [row.id, row])));
252
+ return structuredClone(result);
253
+ })),
254
+ };
255
+ }
256
+ issuer(namespace) {
257
+ const kinds = ['sessions', 'grants', 'refreshTokens', 'authorizations', 'codes', 'signIns', 'accessTokens', 'replay'];
258
+ 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: {} };
260
+ const get = (kind, id) => {
261
+ if (!id)
262
+ return;
263
+ const value = this.one(namespace, kind, id);
264
+ if (value !== undefined)
265
+ state[kind][id] = value;
266
+ };
267
+ const find = (kind, where, ...params) => {
268
+ for (const row of this.rows(namespace, kind, where, params))
269
+ state[kind][row.id] = JSON.parse(row.body);
270
+ };
271
+ if (selection.kind === 'access')
272
+ get('accessTokens', selection.hash);
273
+ if (selection.kind === 'sign_in') {
274
+ for (const id of selection.ids)
275
+ get('signIns', id);
276
+ get('sessions', selection.sessionId);
277
+ get('accessTokens', selection.accessHash);
278
+ for (const key of selection.replayKeys)
279
+ 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);
281
+ state.limits = { sessions: 0, earliestSessionExpiry: 0, pending: Number(counts.n), pendingForClient: Number(counts.client ?? 0) };
282
+ }
283
+ if (selection.kind === 'principal') {
284
+ get('sessions', selection.sessionId);
285
+ get('grants', selection.grantId);
286
+ }
287
+ if (selection.kind === 'browser') {
288
+ get('sessions', selection.sessionId);
289
+ get('replay', selection.replayKey);
290
+ }
291
+ if (selection.kind === 'authorization') {
292
+ get('authorizations', selection.id);
293
+ get('signIns', selection.revocationKey);
294
+ }
295
+ if (selection.kind === 'token') {
296
+ get('sessions', selection.sessionId);
297
+ get('codes', selection.codeHash);
298
+ get('refreshTokens', selection.refreshHash);
299
+ for (const key of selection.replayKeys)
300
+ get('replay', key);
301
+ const counts = this.db.prepare("SELECT count(*) AS n, min(json_extract(body,'$.expiresAt')) AS expiry FROM records WHERE ns=? AND kind='sessions' AND json_extract(body,'$.clientId')=? AND json_extract(body,'$.expiresAt')>?").get(namespace, selection.clientId, selection.at);
302
+ state.limits = { sessions: Number(counts.n), earliestSessionExpiry: Number(counts.expiry ?? selection.at), pending: 0, pendingForClient: 0 };
303
+ }
304
+ if (selection.kind === 'revoke') {
305
+ get('refreshTokens', selection.refreshHash);
306
+ get('replay', selection.replayKey);
307
+ }
308
+ if (selection.kind === 'authorize') {
309
+ get('signIns', clientRevisionKey(selection.clientId));
310
+ const pending = this.db.prepare("SELECT count(*) AS n FROM records WHERE ns=? AND kind='authorizations' AND json_extract(body,'$.expiresAt')>?").get(namespace, selection.at);
311
+ const client = this.db.prepare("SELECT count(*) AS n FROM records WHERE ns=? AND kind='authorizations' AND json_extract(body,'$.clientId')=? AND json_extract(body,'$.expiresAt')>?").get(namespace, selection.clientId, selection.at);
312
+ state.limits = { sessions: 0, earliestSessionExpiry: 0, pending: Number(pending.n), pendingForClient: Number(client.n) };
313
+ }
314
+ if (selection.kind === 'disconnect') {
315
+ for (const kind of ['grants', 'codes'])
316
+ find(kind, "AND json_extract(body,'$.clientId')=? AND json_extract(body,'$.accountId')=?", selection.clientId, selection.accountId);
317
+ find('signIns', "AND json_extract(body,'$.clientId')=? AND json_extract(body,'$.accountId')=?", selection.clientId, selection.accountId);
318
+ get('signIns', clientRevisionKey(selection.clientId));
319
+ get('signIns', accountRevisionKey(selection.clientId, selection.accountId));
320
+ }
321
+ for (const record of Object.values(state.accessTokens)) {
322
+ get('sessions', record.sessionId);
323
+ if (record.grantId)
324
+ get('grants', record.grantId);
325
+ }
326
+ for (const record of [...Object.values(state.sessions), ...Object.values(state.codes)])
327
+ if (record.grantId)
328
+ get('grants', record.grantId);
329
+ for (const record of Object.values(state.codes))
330
+ get('signIns', accountRevisionKey(record.clientId, record.accountId));
331
+ for (const id of Object.values(state.refreshTokens))
332
+ get('grants', id);
333
+ for (const [id, grant] of Object.entries(state.grants)) {
334
+ get('refreshTokens', grant.refreshHash);
335
+ get('refreshTokens', grant.previousHash);
336
+ if (['token', 'revoke', 'disconnect'].includes(selection.kind))
337
+ find('sessions', "AND json_extract(body,'$.grantId')=?", id);
338
+ }
339
+ return state;
340
+ };
341
+ return {
342
+ read: selection => this.run(async () => load(selection)),
343
+ transaction: (selection, change) => this.run(async () => this.atomic(() => {
344
+ const state = load(selection), before = structuredClone(state);
345
+ const result = change(state);
346
+ if (result && typeof result.then === 'function')
347
+ throw new TypeError('async issuer transaction');
348
+ for (const kind of kinds)
349
+ this.save(namespace, kind, before[kind], state[kind]);
350
+ const { version, generation, nonces } = state;
351
+ if (json({ version, generation, nonces }) !== json({ version: before.version, generation: before.generation, nonces: before.nonces }))
352
+ this.put(namespace, 'issuer_meta', 'meta', { version, generation, nonces });
353
+ return structuredClone(result);
354
+ })),
355
+ };
356
+ }
357
+ /** Bounded maintenance; never deletes message receipts, events, grants or work intent. */
358
+ cleanup(namespace, now, limit = 100) {
359
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
360
+ throw new TypeError('cleanup limit');
361
+ return this.run(async () => {
362
+ const rows = this.db.prepare(`SELECT kind,id FROM records 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)<?)) LIMIT ?`).all(namespace, now, now, now, limit);
364
+ for (const row of rows)
365
+ this.db.prepare('DELETE FROM records WHERE ns=? AND kind=? AND id=?').run(namespace, row.kind, row.id);
366
+ return rows.length;
367
+ });
368
+ }
369
+ }
@@ -0,0 +1 @@
1
+ export declare const own: <T>(record: Record<string, T>, key: string) => T | undefined;
package/dist/store.js ADDED
@@ -0,0 +1 @@
1
+ export const own = (record, key) => Object.hasOwn(record, key) ? record[key] : undefined;
@@ -0,0 +1,29 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createRequestAuthenticator, createConversationService } from '@poppykit/server';
3
+ import { createMetadataResolver, createNodeTransport, createReferenceIssuer } from '@poppykit/server/reference-auth';
4
+ import { SqliteCompanyDatabase } from '@poppykit/server/sqlite';
5
+
6
+ const database = new SqliteCompanyDatabase(':memory:'); // Non-durable example; use a private file in production.
7
+ const transport = createNodeTransport();
8
+ try {
9
+ const issuer = 'https://company.example/issuer';
10
+ const endpoint = 'https://company.example/conversations';
11
+ const auth = createReferenceIssuer({
12
+ issuer, tokenEndpoint: issuer + '/token', revocationEndpoint: issuer + '/revoke',
13
+ poppyDomains: ['company.example'], directScopes: [], resources: [endpoint],
14
+ metadata: createMetadataResolver(transport), store: database.issuer(issuer),
15
+ coordinator: database, now: Date.now,
16
+ });
17
+ const conversations = createConversationService({
18
+ endpoint, store: database.conversations(issuer), policy: auth.authority.policy,
19
+ authenticator: createRequestAuthenticator({ authority: auth.authority,
20
+ endpoints: [{ origin: 'https://company.example', pathPrefix: '/conversations', resource: endpoint }],
21
+ now: Date.now }), clock: { now: Date.now },
22
+ });
23
+ // 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);
25
+ console.log('Reference issuer and conversation service configured; anonymous request rejected.');
26
+ } finally {
27
+ transport.close?.();
28
+ database.close();
29
+ }
package/package.json CHANGED
@@ -1,6 +1,69 @@
1
1
  {
2
2
  "name": "@poppykit/server",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "description": "Personal Agent Protocol company SDK",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/LMol-4/poppykit-server.git"
9
+ },
10
+ "type": "module",
11
+ "engines": {
12
+ "node": ">=24.12.0"
13
+ },
14
+ "packageManager": "pnpm@11.25.0",
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "pack": "pnpm pack",
18
+ "test": "npm run build:tests && node scripts/run-checks.mjs all",
19
+ "typecheck": "tsc --noEmit",
20
+ "check:docs": "node scripts/check-docs.mjs",
21
+ "typecheck:tests": "tsc -p tsconfig.tests.json --noEmit",
22
+ "build:tests": "node scripts/build-tests.mjs",
23
+ "coverage": "npm run build && npm run build:tests && node scripts/coverage.mjs"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "24.10.0",
27
+ "typescript": "5.9.3"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "README.md",
32
+ "STORAGE.md",
33
+ "AUTH.md",
34
+ "DELIVERY.md",
35
+ "INTEGRATIONS.md",
36
+ "API.md",
37
+ "CONTRIBUTING.md",
38
+ "SECURITY.md",
39
+ "CHANGELOG.md",
40
+ "RELEASING.md",
41
+ "examples",
42
+ "LICENSE"
43
+ ],
44
+ "exports": {
45
+ ".": {
46
+ "types": "./dist/index.d.ts",
47
+ "import": "./dist/index.js"
48
+ },
49
+ "./adapter-contract": {
50
+ "types": "./dist/adapter-contract.d.ts",
51
+ "import": "./dist/adapter-contract.js"
52
+ },
53
+ "./reference-auth": {
54
+ "types": "./dist/reference-auth/index.d.ts",
55
+ "import": "./dist/reference-auth/index.js"
56
+ },
57
+ "./sqlite": {
58
+ "types": "./dist/sqlite.d.ts",
59
+ "import": "./dist/sqlite.js"
60
+ }
61
+ },
62
+ "dependencies": {
63
+ "jose": "6.2.12"
64
+ },
65
+ "license": "MIT",
66
+ "publishConfig": {
67
+ "access": "public"
68
+ }
69
+ }