@poppykit/client 0.0.0-stage → 0.1.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 (50) hide show
  1. package/API.md +25 -0
  2. package/AUTH.md +51 -0
  3. package/CHANGELOG.md +22 -0
  4. package/CONTRIBUTING.md +50 -0
  5. package/DELIVERY.md +113 -0
  6. package/INTEGRATIONS.md +87 -0
  7. package/LICENSE +21 -0
  8. package/PROVIDER.md +99 -0
  9. package/README.md +123 -2
  10. package/RELEASING.md +193 -0
  11. package/SECURITY.md +13 -0
  12. package/STORAGE.md +99 -0
  13. package/dist/auth/challenge.d.ts +19 -0
  14. package/dist/auth/challenge.js +68 -0
  15. package/dist/auth/client-auth.d.ts +297 -0
  16. package/dist/auth/client-auth.js +1583 -0
  17. package/dist/auth/ownership.d.ts +9 -0
  18. package/dist/auth/ownership.js +23 -0
  19. package/dist/auth/sqlite-store.d.ts +14 -0
  20. package/dist/auth/sqlite-store.js +123 -0
  21. package/dist/auth/store.d.ts +19 -0
  22. package/dist/auth/store.js +1 -0
  23. package/dist/conversation-store.d.ts +15 -0
  24. package/dist/conversation-store.js +1 -0
  25. package/dist/conversation.d.ts +118 -0
  26. package/dist/conversation.js +404 -0
  27. package/dist/index.d.ts +18 -0
  28. package/dist/index.js +7 -0
  29. package/dist/integrations.d.ts +30 -0
  30. package/dist/integrations.js +58 -0
  31. package/dist/memory.d.ts +15 -0
  32. package/dist/memory.js +32 -0
  33. package/dist/network/discovery.d.ts +101 -0
  34. package/dist/network/discovery.js +276 -0
  35. package/dist/network/transport.d.ts +31 -0
  36. package/dist/network/transport.js +305 -0
  37. package/dist/protocol/sse.d.ts +15 -0
  38. package/dist/protocol/sse.js +90 -0
  39. package/dist/protocol/wire.d.ts +128 -0
  40. package/dist/protocol/wire.js +278 -0
  41. package/dist/provider-sqlite.d.ts +8 -0
  42. package/dist/provider-sqlite.js +30 -0
  43. package/dist/provider.d.ts +121 -0
  44. package/dist/provider.js +342 -0
  45. package/dist/sqlite-init.d.ts +2 -0
  46. package/dist/sqlite-init.js +17 -0
  47. package/dist/sqlite.d.ts +20 -0
  48. package/dist/sqlite.js +80 -0
  49. package/examples/quickstart.mjs +19 -0
  50. package/package.json +72 -4
@@ -0,0 +1,9 @@
1
+ export interface OperationOwner {
2
+ host: string;
3
+ pid: number;
4
+ id: string;
5
+ }
6
+ export declare function acquireOwner(): OperationOwner;
7
+ export declare function releaseOwner(owner: OperationOwner): void;
8
+ /** Unknown remote owners and permission failures are conservatively live. */
9
+ export declare function localOwnerAlive(owner: OperationOwner): boolean;
@@ -0,0 +1,23 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { hostname } from 'node:os';
3
+ const active = new Set();
4
+ export function acquireOwner() {
5
+ const owner = { host: hostname(), pid: process.pid, id: randomUUID() };
6
+ active.add(owner.id);
7
+ return owner;
8
+ }
9
+ export function releaseOwner(owner) { active.delete(owner.id); }
10
+ /** Unknown remote owners and permission failures are conservatively live. */
11
+ export function localOwnerAlive(owner) {
12
+ if (owner.host !== hostname())
13
+ return true;
14
+ if (owner.pid === process.pid)
15
+ return active.has(owner.id);
16
+ try {
17
+ process.kill(owner.pid, 0);
18
+ return true;
19
+ }
20
+ catch (error) {
21
+ return error.code !== 'ESRCH';
22
+ }
23
+ }
@@ -0,0 +1,14 @@
1
+ import type { ClientAuthState } from './client-auth.js';
2
+ import type { AuthSelection, AuthStore } from './store.js';
3
+ /** One database per local auth namespace/user/client. References only: secrets belong in SecretStore. */
4
+ export declare class SqliteAuthStore implements AuthStore {
5
+ private readonly db;
6
+ constructor(path: string);
7
+ close(): void;
8
+ pendingOperations(after: string | undefined, limit: number): Promise<string[]>;
9
+ /** Explicit bounded history cleanup; uncertain outcomes and pending operations always remain. */
10
+ cleanup(before: number, limit?: number): Promise<number>;
11
+ private select;
12
+ read(selection: AuthSelection): Promise<ClientAuthState>;
13
+ transaction<T>(selection: AuthSelection, change: (draft: ClientAuthState) => T): Promise<T>;
14
+ }
@@ -0,0 +1,123 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { initializeSqlite } from '../sqlite-init.js';
3
+ import { initialClientAuthState } from './client-auth.js';
4
+ const tables = ['identities', 'connections', 'grants', 'operations', 'cleanup'];
5
+ function json(value) {
6
+ return JSON.stringify(value, (_key, item) => {
7
+ if (item === undefined || typeof item === 'function' || typeof item === 'bigint' || (typeof item === 'number' && !Number.isFinite(item)))
8
+ throw new TypeError('non-JSON auth record');
9
+ return item;
10
+ });
11
+ }
12
+ /** One database per local auth namespace/user/client. References only: secrets belong in SecretStore. */
13
+ export class SqliteAuthStore {
14
+ db;
15
+ constructor(path) {
16
+ this.db = new DatabaseSync(path);
17
+ initializeSqlite(this.db, () => {
18
+ this.db.exec('PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;');
19
+ this.db.exec('CREATE TABLE IF NOT EXISTS auth_meta (id INTEGER PRIMARY KEY CHECK(id=1), body TEXT NOT NULL)');
20
+ for (const table of tables)
21
+ this.db.exec(`CREATE TABLE IF NOT EXISTS auth_${table} (id TEXT PRIMARY KEY, body TEXT NOT NULL)`);
22
+ this.db.exec(`CREATE INDEX IF NOT EXISTS operations_connection ON auth_operations(json_extract(body,'$.connectionId'));
23
+ CREATE UNIQUE INDEX IF NOT EXISTS operations_callback ON auth_operations(json_extract(body,'$.direct.stateHash'));
24
+ CREATE INDEX IF NOT EXISTS operations_pending ON auth_operations(id) WHERE json_extract(body,'$.phase')!='terminal';
25
+ CREATE INDEX IF NOT EXISTS operations_cleanup ON auth_operations(json_extract(body,'$.createdAt')) WHERE json_extract(body,'$.phase')='terminal' AND json_extract(body,'$.status')!='uncertain';
26
+ CREATE INDEX IF NOT EXISTS cleanup_connection ON auth_cleanup(json_extract(body,'$.connectionId'));`);
27
+ const { version, epoch, owner } = initialClientAuthState();
28
+ this.db.prepare('INSERT OR IGNORE INTO auth_meta VALUES (1,?)').run(json({ version, epoch, owner }));
29
+ });
30
+ }
31
+ close() { this.db.close(); }
32
+ async pendingOperations(after, limit) {
33
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
34
+ throw new TypeError('recovery limit');
35
+ return this.db.prepare("SELECT id FROM auth_operations WHERE json_extract(body,'$.phase')!='terminal' AND id>? ORDER BY id LIMIT ?").all(after ?? '', limit).map(row => row.id);
36
+ }
37
+ /** Explicit bounded history cleanup; uncertain outcomes and pending operations always remain. */
38
+ async cleanup(before, limit = 100) {
39
+ if (!Number.isFinite(before) || !Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
40
+ throw new TypeError('cleanup limit');
41
+ return Number(this.db.prepare("DELETE FROM auth_operations WHERE id IN (SELECT id FROM auth_operations WHERE json_extract(body,'$.phase')='terminal' AND json_extract(body,'$.status')!='uncertain' AND json_extract(body,'$.createdAt')<? ORDER BY json_extract(body,'$.createdAt') LIMIT ?)").run(before, limit).changes);
42
+ }
43
+ select(selection) {
44
+ const state = { ...JSON.parse(this.db.prepare('SELECT body FROM auth_meta WHERE id=1').get().body), identities: {}, connections: {}, grants: {}, operations: {}, cleanup: {} };
45
+ const get = (table, id) => {
46
+ const row = this.db.prepare(`SELECT body FROM auth_${table} WHERE id=?`).get(id);
47
+ if (row)
48
+ state[table][id] = JSON.parse(row.body);
49
+ };
50
+ const rows = (table, clause, ...params) => {
51
+ for (const row of this.db.prepare(`SELECT id,body FROM auth_${table} ${clause}`).all(...params))
52
+ state[table][row.id] = JSON.parse(row.body);
53
+ };
54
+ if ('issuer' in selection) {
55
+ get('identities', selection.issuer);
56
+ return state;
57
+ }
58
+ if ('connectionId' in selection)
59
+ get('connections', selection.connectionId);
60
+ else if ('operationId' in selection)
61
+ get('operations', selection.operationId);
62
+ else if ('callbackHash' in selection)
63
+ rows('operations', "WHERE json_extract(body,'$.direct.stateHash')=?", selection.callbackHash);
64
+ else if ('list' in selection)
65
+ rows('connections', '');
66
+ for (const operation of Object.values(state.operations))
67
+ get('connections', operation.connectionId);
68
+ for (const connection of Object.values(state.connections)) {
69
+ if (connection.grantId)
70
+ get('grants', connection.grantId);
71
+ if (!('list' in selection)) {
72
+ rows('operations', "WHERE json_extract(body,'$.connectionId')=?", connection.id);
73
+ rows('cleanup', "WHERE json_extract(body,'$.connectionId')=?", connection.id);
74
+ }
75
+ }
76
+ for (const operation of Object.values(state.operations))
77
+ if (operation.grantId)
78
+ get('grants', operation.grantId);
79
+ return state;
80
+ }
81
+ async read(selection) {
82
+ this.db.exec('BEGIN');
83
+ try {
84
+ const state = this.select(selection);
85
+ this.db.exec('COMMIT');
86
+ return state;
87
+ }
88
+ catch (error) {
89
+ this.db.exec('ROLLBACK');
90
+ throw error;
91
+ }
92
+ }
93
+ async transaction(selection, change) {
94
+ this.db.exec('BEGIN IMMEDIATE');
95
+ try {
96
+ const state = this.select(selection), before = structuredClone(state);
97
+ const result = change(state);
98
+ if (result && typeof result.then === 'function')
99
+ throw new TypeError('async auth transaction');
100
+ const { version, epoch, owner } = state;
101
+ const metadata = json({ version, epoch, owner });
102
+ if (metadata !== json({ version: before.version, epoch: before.epoch, owner: before.owner }))
103
+ this.db.prepare('UPDATE auth_meta SET body=? WHERE id=1').run(metadata);
104
+ for (const table of tables) {
105
+ for (const id of Object.keys(before[table]))
106
+ if (!Object.hasOwn(state[table], id))
107
+ this.db.prepare(`DELETE FROM auth_${table} WHERE id=?`).run(id);
108
+ for (const [id, value] of Object.entries(state[table])) {
109
+ const body = json(value);
110
+ if (!Object.hasOwn(before[table], id) || body !== json(before[table][id]))
111
+ this.db.prepare(`INSERT INTO auth_${table} VALUES (?,?) ON CONFLICT(id) DO UPDATE SET body=excluded.body`).run(id, body);
112
+ }
113
+ }
114
+ const detached = structuredClone(result);
115
+ this.db.exec('COMMIT');
116
+ return detached;
117
+ }
118
+ catch (error) {
119
+ this.db.exec('ROLLBACK');
120
+ throw error;
121
+ }
122
+ }
123
+ }
@@ -0,0 +1,19 @@
1
+ import type { ClientAuthState } from './client-auth.js';
2
+ /** A connection aggregate includes its grants, operations and cleanup; never other connections. */
3
+ export type AuthSelection = {
4
+ connectionId: string;
5
+ } | {
6
+ operationId: string;
7
+ } | {
8
+ issuer: string;
9
+ } | {
10
+ callbackHash: string;
11
+ } | {
12
+ list: true;
13
+ };
14
+ export interface AuthStore {
15
+ read(selection: AuthSelection): Promise<ClientAuthState>;
16
+ transaction<T>(selection: AuthSelection, change: (draft: ClientAuthState) => T): Promise<T>;
17
+ /** Ascending IDs strictly after the cursor. Includes only nonterminal operations. */
18
+ pendingOperations(after: string | undefined, limit: number): Promise<string[]>;
19
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import type { ConsumerCheckpoint, OutboundRecord } from './conversation.js';
2
+ export interface ConversationKey {
3
+ connectionId: string;
4
+ issuer: string;
5
+ endpoint: string;
6
+ }
7
+ export interface ConversationStore {
8
+ getOutbound(key: ConversationKey, id: string): Promise<OutboundRecord | undefined>;
9
+ /** Insert or return the existing record atomically; never replace its destination/content. */
10
+ prepareOutbound(key: ConversationKey, record: OutboundRecord): Promise<OutboundRecord>;
11
+ settleOutbound(key: ConversationKey, id: string, result: Pick<OutboundRecord, 'phase' | 'response' | 'error'>): Promise<void>;
12
+ getCheckpoint(key: ConversationKey, conversationId: string, name: string): Promise<string | undefined>;
13
+ advanceCheckpoint(key: ConversationKey, checkpoint: ConsumerCheckpoint, from: readonly (string | undefined)[]): Promise<void>;
14
+ resetCheckpoint(key: ConversationKey, conversationId: string, name: string): Promise<void>;
15
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,118 @@
1
+ import type { AuthorizationRequirement } from './auth/client-auth.js';
2
+ import type { ConversationEvent } from './protocol/wire.js';
3
+ import type { ConversationStore } from './conversation-store.js';
4
+ import type { ConversationRequest, ConversationState, EventPage, Message } from './protocol/wire.js';
5
+ export interface OutboundRecord {
6
+ connectionId: string;
7
+ issuer: string;
8
+ endpoint: string;
9
+ id: string;
10
+ target: string | null;
11
+ request: ConversationRequest;
12
+ fingerprint: string;
13
+ phase: 'pending' | 'accepted' | 'rejected';
14
+ read?: ReadParams;
15
+ response?: ConversationState | EventPage;
16
+ error?: {
17
+ code: string;
18
+ status: number;
19
+ conversationId?: string;
20
+ };
21
+ }
22
+ export interface ReadParams {
23
+ wait?: number;
24
+ cursor?: string;
25
+ signal?: AbortSignal;
26
+ }
27
+ export interface ConsumerCheckpoint {
28
+ connectionId: string;
29
+ issuer: string;
30
+ conversationId: string;
31
+ name: string;
32
+ cursor: string;
33
+ }
34
+ export interface ConversationClientState {
35
+ version: 1;
36
+ outbound: OutboundRecord[];
37
+ consumers: ConsumerCheckpoint[];
38
+ }
39
+ export declare const initialConversationClientState: () => ConversationClientState;
40
+ export declare class ConversationError extends Error {
41
+ readonly code: string;
42
+ readonly status?: number | undefined;
43
+ readonly retryAfterMs?: number | undefined;
44
+ readonly scope?: string | undefined;
45
+ readonly conversationId?: string | undefined;
46
+ readonly extension?: string | undefined;
47
+ readonly extensions: string[];
48
+ constructor(code: string, status?: number | undefined, retryAfterMs?: number | undefined, scope?: string | undefined, conversationId?: string | undefined, extension?: string | undefined, extensions?: string[]);
49
+ get requiredScopes(): string[];
50
+ }
51
+ export interface ConversationClientOptions {
52
+ authorizedRequest(request: Request): Promise<Response>;
53
+ endpoint: string;
54
+ connectionId: string;
55
+ issuer: string;
56
+ store: ConversationStore;
57
+ clock: {
58
+ now(): number;
59
+ };
60
+ newId?: () => string;
61
+ sleep?: (ms: number) => Promise<void>;
62
+ random?: () => number;
63
+ /** Optional safe UI hook supplied by the single authentication lifecycle. */
64
+ authorization?: (connectionId: string, scopes: string[]) => Promise<AuthorizationRequirement>;
65
+ maxAttempts?: number;
66
+ baseDelayMs?: number;
67
+ maxDelayMs?: number;
68
+ maxPages?: number;
69
+ }
70
+ export interface ReadOptions extends ReadParams {
71
+ consumer?: string;
72
+ signal?: AbortSignal;
73
+ }
74
+ export interface WatchOptions extends ReadOptions {
75
+ lastEventId?: string;
76
+ maxReconnects?: number;
77
+ onPartial?: (value: {
78
+ messageId: string;
79
+ text: string;
80
+ } | null) => void;
81
+ onHistoryGap?: () => Promise<boolean> | boolean;
82
+ }
83
+ export interface WatchedEvent {
84
+ event: ConversationEvent;
85
+ ack(): Promise<void>;
86
+ }
87
+ export type OutboundInput = Omit<Message, 'id'> & {
88
+ id?: string;
89
+ };
90
+ export declare function createConversationClient(options: ConversationClientOptions): {
91
+ authorization(event: ConversationEvent | ConversationError): Promise<AuthorizationRequirement>;
92
+ watch(conversationId: string, opts?: WatchOptions): AsyncGenerator<WatchedEvent>;
93
+ start: (message: OutboundInput, opts?: ReadParams & {
94
+ parentConversationId?: string;
95
+ }) => Promise<(ConversationState | EventPage) & {
96
+ messageId: string;
97
+ }>;
98
+ send: (conversationId: string, message: OutboundInput, opts?: ReadParams) => Promise<(ConversationState | EventPage) & {
99
+ messageId: string;
100
+ }>;
101
+ /** Re-dispatch a pending outbound operation with its original ID and content. */
102
+ retry: (id: string, signal?: AbortSignal) => Promise<ConversationState>;
103
+ operation(messageId: string): Promise<OutboundRecord | undefined>;
104
+ handoff: (conversationId: string, opts?: {
105
+ signal?: AbortSignal;
106
+ }) => Promise<ConversationState>;
107
+ close: (conversationId: string, opts?: {
108
+ signal?: AbortSignal;
109
+ }) => Promise<ConversationState>;
110
+ /** Reads one whole page. Explicit `cursor`, or the checkpoint of a named `consumer` (not both). Never acknowledges. */
111
+ read(conversationId: string, opts?: ReadOptions): Promise<EventPage>;
112
+ /** Reads pages until has_more is false. Pages are returned whole, never acknowledged. */
113
+ drain(conversationId: string, opts?: ReadOptions): Promise<EventPage[]>;
114
+ /** Durably advances the consumer to the page's cursor, after the application processed that page. A page never delivered to this consumer by this client instance is refused. */
115
+ ack(conversationId: string, consumer: string, page: EventPage): Promise<void>;
116
+ /** After cursor_expired: forget the checkpoint so the consumer rereads retained history from the start (dedupe by event ID; missing events were not processed). */
117
+ resetConsumer(conversationId: string, consumer: string): Promise<void>;
118
+ };