@poppykit/client 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 (50) hide show
  1. package/API.md +25 -0
  2. package/AUTH.md +51 -0
  3. package/CHANGELOG.md +18 -0
  4. package/CONTRIBUTING.md +38 -0
  5. package/DELIVERY.md +94 -0
  6. package/INTEGRATIONS.md +87 -0
  7. package/LICENSE +21 -0
  8. package/PROVIDER.md +99 -0
  9. package/README.md +49 -2
  10. package/RELEASING.md +91 -0
  11. package/SECURITY.md +13 -0
  12. package/STORAGE.md +96 -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
package/STORAGE.md ADDED
@@ -0,0 +1,96 @@
1
+ # Persistence and recovery
2
+
3
+ Use targeted storage contracts. Node 24.12.0
4
+ or later is required. The optional `@poppykit/client/sqlite` entry point exports
5
+ `SqliteAuthStore` and `SqliteConversationStore`; importing the client core does not
6
+ load SQLite. `node:sqlite` is experimental in the tested Node 24.12.0 runtime.
7
+ Consumers can implement `AuthStore` and `ConversationStore` using their own database.
8
+
9
+ ```ts
10
+ import { SqliteAuthStore, SqliteConversationStore } from '@poppykit/client/sqlite';
11
+ const authStore = new SqliteAuthStore('/private/path/auth.db');
12
+ const conversationStore = new SqliteConversationStore('/private/path/messages.db');
13
+ // Pass authStore to createClientAuth and conversationStore to createConversationClient.
14
+ // Close both after all operations have finished.
15
+ ```
16
+
17
+ Use one auth database per namespace/local user/client identity. The owner tuple is
18
+ checked on every SDK auth read and mutation. Identities are keyed by the exact full
19
+ issuer, connections by immutable IDs, operations by ID and callback state hash,
20
+ cleanup by connection, and token slots by session/resource. Secret values are never
21
+ stored in auth records: immutable references point to the host's `SecretStore`.
22
+ Session Tokens remain in process memory. Conversation databases contain message
23
+ bodies; operators must provide private storage and their required encryption.
24
+
25
+ Auth selections load an identity or a connection aggregate (its grants, operations,
26
+ and cleanup), never unrelated connections. Operation/callback selectors resolve the
27
+ owning connection through indexes. `list` explicitly enumerates connections.
28
+ Recovery pages nonterminal operation IDs in batches of 100, then rechecks each
29
+ operation and its owner inside its transaction. `cleanup(before, limit)` removes
30
+ only terminal, non-uncertain auth history; the limit is at most 1000. It does not
31
+ erase grants, pending exchanges, uncertain results, or secrets.
32
+
33
+ Transactions must serialize conflicting domain updates across processes, roll back
34
+ on exceptions, reject asynchronous mutation callbacks and non-JSON persisted values,
35
+ enforce unique keys, and return detached values. SQLite uses WAL, FULL synchronous
36
+ commits and a bounded busy wait. It serializes writers; parallel writers are not a
37
+ promise. The database file must be on a local filesystem that supports SQLite's
38
+ locking; this is not a distributed database. Create adapters before starting work,
39
+ and keep network/vault calls outside transaction callbacks.
40
+
41
+ Conversation storage has separate prepare/get/settle operations and checkpoint
42
+ compare-and-set. An outbound ID is unique per connection/full issuer, and also
43
+ records its endpoint. Retry rejects an endpoint change before sending. Preparation
44
+ returns the existing record instead of replacing content; terminal outcomes cannot
45
+ be overwritten by a late sender. Checkpoints include endpoint, conversation and
46
+ consumer. Receipts remain available for retries: the old `keepCompleted` deletion
47
+ behavior is removed. Retention preserves message identity and bounds payload cleanup; no automatic
48
+ age-based deletion is performed.
49
+
50
+ `MemoryConversationStore` from `@poppykit/client/memory` is non-durable and confined
51
+ to one process. The SQLite adapters also accept `:memory:` for non-durable tests;
52
+ each such adapter has its own database. Neither mode demonstrates process recovery.
53
+
54
+ ## Operation ownership
55
+
56
+ Each active rotation or code exchange records a random execution owner, host name,
57
+ and PID before dispatch. The default liveness check keeps other running local
58
+ processes live; within this process it tracks actual active executions. Unknown
59
+ remote hosts and permission errors are conservatively live. A new invocation cannot
60
+ invalidate an active exchange just by starting recovery. A reused PID can delay
61
+ recovery; it cannot authorize replay. Multi-host adapters must supply a trustworthy
62
+ `ownerAlive` hook backed by their deployment's ownership/fencing mechanism. Returning
63
+ false is an assertion of abandonment, not a request to cancel an active operation.
64
+
65
+ Recovery rechecks phase/generation and liveness under the database transaction. A
66
+ prepared abandoned rotation can release its claim; a dispatched exchange without a
67
+ durably staged response becomes uncertain. A staged rotation can activate locally
68
+ only if its connection/grant generations still match. Cancellation, disconnection,
69
+ or another generation wins over late activation. No remote token exchange is replayed
70
+ by recovery, and lease expiry would not prove that a request was never sent.
71
+
72
+ The vault and remote issuer are outside the database transaction. Stage immutable
73
+ secrets first, conditionally activate references, then remove superseded secrets.
74
+ Vault failure or process death can leave an unreferenced staged secret; the host
75
+ needs an orphan-secret sweep that respects active owners and a grace period. A lost
76
+ remote rotation response remains uncertain. This adapter does not promise automatic
77
+ recovery of a token that the issuer rotated but never delivered.
78
+
79
+ ## Development reset
80
+
81
+ There is no automatic migration from old whole-state development stores. Stop all
82
+ users of a disposable store, resolve uncertain remote outcomes, and explicitly move
83
+ aside its database and WAL/SHM files and the matching host-managed secret namespace
84
+ before creating a fresh store. Never reset live credentials by deleting a database
85
+ behind a running process. Old stores are not silently deleted or reinterpreted.
86
+
87
+ Contract tests cover real child-process contention, crash recovery, uncertain
88
+ external effects, staged secrets and revocation races; see [contributing](CONTRIBUTING.md).
89
+
90
+ ## Conversation delivery lifetime
91
+
92
+ See [DELIVERY.md](DELIVERY.md) for the retention table. Outbound records and
93
+ checkpoints are retained until explicit local deletion; pending/uncertain retries
94
+ must first be reconciled. Checkpoint acknowledgements use compare-and-set and do
95
+ not acknowledge merely because a page or stream frame was read. Local deletion
96
+ never deletes company history, revokes access or cancels accepted business work.
@@ -0,0 +1,19 @@
1
+ export declare function schemeChallenge(header: string | null, scheme: 'DPoP' | 'Bearer'): {
2
+ error?: string;
3
+ scope?: string;
4
+ extension?: string;
5
+ conversation_id?: string;
6
+ } | undefined;
7
+ export declare const dpopChallenge: (header: string | null) => {
8
+ error?: string;
9
+ scope?: string;
10
+ extension?: string;
11
+ conversation_id?: string;
12
+ } | undefined;
13
+ export interface AuthChallenge {
14
+ error?: string;
15
+ requiredScopes: string[];
16
+ extensions: string[];
17
+ conversationId?: string;
18
+ }
19
+ export declare function authChallenge(header: string | null, body?: unknown): AuthChallenge;
@@ -0,0 +1,68 @@
1
+ // Reads the DPoP challenge from a WWW-Authenticate header (RFC 9110 11.6.1, spec 6); other schemes' params are ignored.
2
+ const TOKEN = /[A-Za-z0-9!#$%&'*+.^_`|~-]+/y;
3
+ export function schemeChallenge(header, scheme) {
4
+ if (!header)
5
+ return undefined;
6
+ let i = 0;
7
+ let current;
8
+ let found;
9
+ const skip = (chars) => { while (i < header.length && chars.test(header[i]))
10
+ i++; };
11
+ while (i < header.length) {
12
+ skip(/[\s,]/);
13
+ TOKEN.lastIndex = i;
14
+ const match = TOKEN.exec(header);
15
+ if (!match) {
16
+ i++;
17
+ continue;
18
+ }
19
+ i += match[0].length;
20
+ const afterToken = i;
21
+ skip(/\s/);
22
+ if (header[i] !== '=' || !current) {
23
+ if (header[i] === '=') {
24
+ i = afterToken;
25
+ skip(/[=\s]/);
26
+ continue;
27
+ } // token68 or stray param before any scheme
28
+ current = { scheme: match[0].toLowerCase(), params: {} };
29
+ if (current.scheme === scheme.toLowerCase())
30
+ found = current.params;
31
+ i = afterToken;
32
+ continue;
33
+ }
34
+ i++;
35
+ skip(/\s/);
36
+ let value = '';
37
+ if (header[i] === '"') {
38
+ i++;
39
+ while (i < header.length && header[i] !== '"') {
40
+ if (header[i] === '\\')
41
+ i++;
42
+ value += header[i] ?? '';
43
+ i++;
44
+ }
45
+ i++;
46
+ }
47
+ else {
48
+ TOKEN.lastIndex = i;
49
+ value = TOKEN.exec(header)?.[0] ?? '';
50
+ i += value.length;
51
+ }
52
+ const name = match[0].toLowerCase();
53
+ if (current.scheme === scheme.toLowerCase() && ['error', 'scope', 'extension', 'conversation_id'].includes(name))
54
+ current.params[name] = value;
55
+ }
56
+ return found;
57
+ }
58
+ export const dpopChallenge = (header) => schemeChallenge(header, 'DPoP');
59
+ // Combine independent detail channels: a sparse JSON error must not erase header scopes/extensions.
60
+ export function authChallenge(header, body) {
61
+ const challenge = dpopChallenge(header), json = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
62
+ const scopes = [challenge?.scope, json.scope].flatMap(value => typeof value === 'string' ? value.split(' ').filter(scope => /^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope)) : []);
63
+ const extensions = [challenge?.extension, json.extension].filter((value) => typeof value === 'string' && value.length <= 512 && /^(?:[A-Za-z0-9._-]+|(?:[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\.)+[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*)$/.test(value));
64
+ const safe = new Set(['sign_in_required', 'insufficient_scope', 'invalid_token', 'invalid_dpop_proof', 'use_dpop_nonce', 'extension_required', 'rate_limited', 'invalid_scope']);
65
+ const error = [challenge?.error, json.error].find((value) => typeof value === 'string' && safe.has(value));
66
+ const conversationId = [json.conversation_id, challenge?.conversation_id].find((value) => typeof value === 'string' && value.length > 0 && value.length <= 256);
67
+ return { ...(error ? { error } : {}), requiredScopes: [...new Set(scopes)], extensions: [...new Set(extensions)], ...(conversationId ? { conversationId } : {}) };
68
+ }
@@ -0,0 +1,297 @@
1
+ import type { AuthSelection, AuthStore } from './store.js';
2
+ import type { OperationOwner } from './ownership.js';
3
+ import type { JsonObject } from '../protocol/wire.js';
4
+ import type { CompanyMetadata, MetadataResolver } from '../network/discovery.js';
5
+ import type { HttpTransport } from '../network/transport.js';
6
+ import type { ApiTarget, AgentBrowserController, FeedbackReport } from '../integrations.js';
7
+ export type { AuthStore, AuthSelection };
8
+ export interface SecretStore {
9
+ stage(secret: string): Promise<string>;
10
+ read(reference: string): Promise<string | null>;
11
+ remove(reference: string): Promise<void>;
12
+ }
13
+ export interface IdentitySigner {
14
+ readonly kid: string;
15
+ publicJwk(): Promise<JsonObject>;
16
+ sign(input: Uint8Array): Promise<Uint8Array>;
17
+ }
18
+ export type AssertionRequest = {
19
+ operationId: string;
20
+ issuer: string;
21
+ } & ({
22
+ purpose: 'client-authentication';
23
+ } | {
24
+ purpose: 'session';
25
+ } | {
26
+ purpose: 'browser';
27
+ sessionId: string;
28
+ returnTo: string;
29
+ });
30
+ export interface AssertionProvider {
31
+ assert(request: AssertionRequest): Promise<{
32
+ assertion: string;
33
+ expiresAt: number;
34
+ }>;
35
+ }
36
+ export interface ProviderIdentity {
37
+ identity(issuer: string): Promise<{
38
+ clientId: string;
39
+ userId: string;
40
+ }>;
41
+ }
42
+ export type SignInMethod = 'direct' | 'device' | 'mediated';
43
+ export interface AuthorizationRequirement {
44
+ requiredScopes: string[];
45
+ grantedScopes: string[];
46
+ missingScopes: string[];
47
+ sufficient: boolean;
48
+ methods: {
49
+ method: SignInMethod;
50
+ scopes: string[];
51
+ }[];
52
+ organization: {
53
+ name: string;
54
+ domain: string;
55
+ };
56
+ }
57
+ export interface TrustedInput {
58
+ collect(request: {
59
+ operationId: string;
60
+ issuer: string;
61
+ organization: {
62
+ name: string;
63
+ domain: string;
64
+ };
65
+ fields: {
66
+ name: string;
67
+ label: string;
68
+ secret: boolean;
69
+ }[];
70
+ challenge?: {
71
+ id: string;
72
+ sentTo: string;
73
+ expiresAt: number;
74
+ };
75
+ }): Promise<Record<string, string> | null>;
76
+ }
77
+ export interface SignInInput {
78
+ method?: SignInMethod;
79
+ scopes: string[];
80
+ redirectUri?: string;
81
+ browserBinding?: string;
82
+ force?: boolean;
83
+ requiredExistingScopes?: string[];
84
+ }
85
+ export interface SignInStart {
86
+ outcome: AuthOutcome;
87
+ authorizationUrl?: string;
88
+ device?: {
89
+ verificationUri: string;
90
+ verificationUriComplete?: string;
91
+ userCode: string;
92
+ organization: {
93
+ name: string;
94
+ domain: string;
95
+ };
96
+ nextPollAt: number;
97
+ };
98
+ }
99
+ export interface ClientAuthOptions {
100
+ namespace: string;
101
+ localUserId: string;
102
+ clientId: string;
103
+ redirectUris: readonly string[];
104
+ identity?: IdentitySigner;
105
+ assertions?: AssertionProvider;
106
+ providerIdentity?: ProviderIdentity;
107
+ trustedInput?: TrustedInput;
108
+ store: AuthStore;
109
+ secrets: SecretStore;
110
+ transport: HttpTransport;
111
+ metadata: MetadataResolver;
112
+ now?: () => number;
113
+ ownerAlive?: (owner: OperationOwner) => boolean;
114
+ }
115
+ export type ConnectionStatus = 'signed_out' | 'signed_in' | 'disconnected' | 'uncertain';
116
+ export interface Connection {
117
+ id: string;
118
+ domain: string;
119
+ issuer: string;
120
+ organization: {
121
+ name: string;
122
+ domain: string;
123
+ };
124
+ sessionId: string | null;
125
+ generation: number;
126
+ status: ConnectionStatus;
127
+ scopes: string[];
128
+ }
129
+ export type OperationStatus = 'pending' | 'completed' | 'denied' | 'expired' | 'cancelled' | 'failed' | 'uncertain';
130
+ export interface AuthOutcome {
131
+ operationId: string;
132
+ status: OperationStatus;
133
+ connection: Connection;
134
+ error?: string;
135
+ grantedScopes?: string[];
136
+ missingScopes?: string[];
137
+ retryAfterMs?: number;
138
+ nextAction?: 'wait' | 'sign_in' | 'retry_cleanup';
139
+ }
140
+ export interface SignOutOutcome {
141
+ connection: Connection;
142
+ cleanup: 'complete' | 'pending' | 'uncertain';
143
+ }
144
+ export interface ConversationTarget {
145
+ issuer: string;
146
+ endpoint: string;
147
+ resource?: string;
148
+ }
149
+ export interface ClientAuth {
150
+ start(domain: string, input?: {
151
+ anonymous?: boolean;
152
+ }): Promise<AuthOutcome>;
153
+ get(connectionId: string): Promise<Connection | null>;
154
+ list(): Promise<Connection[]>;
155
+ target(connectionId: string): Promise<ConversationTarget>;
156
+ authorization(connectionId: string, requiredScopes: readonly string[]): Promise<AuthorizationRequirement>;
157
+ beginSignIn(connectionId: string, input: SignInInput): Promise<SignInStart>;
158
+ continueSignIn(operationId: string): Promise<AuthOutcome>;
159
+ completeSignIn(input: {
160
+ callbackUrl: string;
161
+ browserBinding: string;
162
+ }): Promise<AuthOutcome>;
163
+ cancelSignIn(operationId: string): Promise<AuthOutcome>;
164
+ operation(operationId: string): Promise<AuthOutcome | null>;
165
+ renew(connectionId: string, input?: {
166
+ resource?: string;
167
+ scopes?: string[];
168
+ }): Promise<AuthOutcome>;
169
+ signOut(connectionId: string): Promise<SignOutOutcome>;
170
+ retryCleanup(connectionId: string): Promise<SignOutOutcome>;
171
+ recover(): Promise<AuthOutcome[]>;
172
+ authorizedFetch(connectionId: string, request: Request, target: ConversationTarget, requiredScopes?: readonly string[]): Promise<Response>;
173
+ apiTarget(connectionId: string, apiUrl: string, destination?: string): Promise<ApiTarget>;
174
+ apiFetch(connectionId: string, request: Request, target: ApiTarget, requiredScopes?: readonly string[]): Promise<Response>;
175
+ joinBrowser(connectionId: string, returnTo: string, controller: AgentBrowserController): Promise<void>;
176
+ feedback(connectionId: string, report: FeedbackReport): Promise<void>;
177
+ }
178
+ export interface CredentialRecord {
179
+ secretRef: string;
180
+ tokenType: 'DPoP' | 'Bearer';
181
+ dpopKeyRef: string;
182
+ scopes: string[];
183
+ expiresAt: number;
184
+ generation: number;
185
+ }
186
+ export interface ConnectionRecord {
187
+ id: string;
188
+ domain: string;
189
+ issuer: string;
190
+ metadata: CompanyMetadata;
191
+ userId: string;
192
+ dpopKeyRef: string;
193
+ sessionId: string | null;
194
+ generation: number;
195
+ status: ConnectionStatus;
196
+ grantId: string | null;
197
+ slots: Record<string, CredentialRecord>;
198
+ anonymous?: boolean;
199
+ notBefore?: string;
200
+ }
201
+ export interface GrantRecord {
202
+ id: string;
203
+ issuer: string;
204
+ userId: string;
205
+ secretRef: string;
206
+ scopes: string[];
207
+ expiresAt: number | null;
208
+ generation: number;
209
+ status: 'active' | 'uncertain' | 'revoking' | 'revoked';
210
+ operationId: string | null;
211
+ }
212
+ export interface DirectRecord {
213
+ stateHash: string;
214
+ verifierRef: string;
215
+ browserHash: string;
216
+ redirectUri: string;
217
+ issuer: string;
218
+ clientId: string;
219
+ sessionId: string;
220
+ }
221
+ export interface OperationRecord {
222
+ owner?: OperationOwner;
223
+ epoch: string;
224
+ id: string;
225
+ kind: 'start' | 'renew' | 'sign_in';
226
+ connectionId: string;
227
+ connectionGeneration: number;
228
+ grantId: string | null;
229
+ grantGeneration: number | null;
230
+ phase: 'prepared' | 'dispatched' | 'staged' | 'terminal';
231
+ status: OperationStatus;
232
+ createdAt: number;
233
+ expiresAt: number;
234
+ resource: string | null;
235
+ scopes: string[];
236
+ direct: DirectRecord | null;
237
+ staged: StagedRefresh | null;
238
+ result: AuthOutcome | null;
239
+ method?: SignInMethod;
240
+ requiredExistingScopes?: string[];
241
+ signInSessionId?: string;
242
+ device?: {
243
+ codeRef: string;
244
+ intervalMs: number;
245
+ nextPollAt: number;
246
+ };
247
+ mediated?: {
248
+ challengeId: string;
249
+ sentTo: string;
250
+ };
251
+ cancelRequested?: boolean;
252
+ }
253
+ export interface StagedRefresh {
254
+ tokenType?: 'DPoP' | 'Bearer';
255
+ accessRef: string;
256
+ refreshRef: string | null;
257
+ sessionId: string;
258
+ scopes: string[];
259
+ signedIn: boolean;
260
+ expiresAt: number;
261
+ refreshExpiresAt: number | null;
262
+ }
263
+ export interface CleanupRecord {
264
+ id: string;
265
+ connectionId: string;
266
+ issuer: string;
267
+ revocationEndpoint: string;
268
+ secretRef: string;
269
+ status: 'pending' | 'uncertain';
270
+ notBefore?: string;
271
+ }
272
+ export interface ClientAuthState {
273
+ version: 1;
274
+ epoch: string;
275
+ owner: {
276
+ namespace: string;
277
+ clientId: string;
278
+ localUserId: string;
279
+ } | null;
280
+ identities: Record<string, {
281
+ userId: string;
282
+ dpopKeyRef: string;
283
+ }>;
284
+ connections: Record<string, ConnectionRecord>;
285
+ grants: Record<string, GrantRecord>;
286
+ operations: Record<string, OperationRecord>;
287
+ cleanup: Record<string, CleanupRecord>;
288
+ }
289
+ export declare class AuthFailure extends Error {
290
+ readonly code: string;
291
+ readonly connectionId?: string | undefined;
292
+ readonly retryAfterMs?: number | undefined;
293
+ readonly retryAt?: string | undefined;
294
+ constructor(code: string, connectionId?: string | undefined, retryAfterMs?: number | undefined, retryAt?: string | undefined);
295
+ }
296
+ export declare function initialClientAuthState(): ClientAuthState;
297
+ export declare function createClientAuth(options: ClientAuthOptions): ClientAuth;