@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.
- package/API.md +25 -0
- package/AUTH.md +51 -0
- package/CHANGELOG.md +18 -0
- package/CONTRIBUTING.md +38 -0
- package/DELIVERY.md +94 -0
- package/INTEGRATIONS.md +87 -0
- package/LICENSE +21 -0
- package/PROVIDER.md +99 -0
- package/README.md +49 -2
- package/RELEASING.md +91 -0
- package/SECURITY.md +13 -0
- package/STORAGE.md +96 -0
- package/dist/auth/challenge.d.ts +19 -0
- package/dist/auth/challenge.js +68 -0
- package/dist/auth/client-auth.d.ts +297 -0
- package/dist/auth/client-auth.js +1583 -0
- package/dist/auth/ownership.d.ts +9 -0
- package/dist/auth/ownership.js +23 -0
- package/dist/auth/sqlite-store.d.ts +14 -0
- package/dist/auth/sqlite-store.js +123 -0
- package/dist/auth/store.d.ts +19 -0
- package/dist/auth/store.js +1 -0
- package/dist/conversation-store.d.ts +15 -0
- package/dist/conversation-store.js +1 -0
- package/dist/conversation.d.ts +118 -0
- package/dist/conversation.js +404 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +7 -0
- package/dist/integrations.d.ts +30 -0
- package/dist/integrations.js +58 -0
- package/dist/memory.d.ts +15 -0
- package/dist/memory.js +32 -0
- package/dist/network/discovery.d.ts +101 -0
- package/dist/network/discovery.js +276 -0
- package/dist/network/transport.d.ts +31 -0
- package/dist/network/transport.js +305 -0
- package/dist/protocol/sse.d.ts +15 -0
- package/dist/protocol/sse.js +90 -0
- package/dist/protocol/wire.d.ts +128 -0
- package/dist/protocol/wire.js +278 -0
- package/dist/provider-sqlite.d.ts +8 -0
- package/dist/provider-sqlite.js +30 -0
- package/dist/provider.d.ts +121 -0
- package/dist/provider.js +342 -0
- package/dist/sqlite-init.d.ts +2 -0
- package/dist/sqlite-init.js +17 -0
- package/dist/sqlite.d.ts +20 -0
- package/dist/sqlite.js +80 -0
- package/examples/quickstart.mjs +19 -0
- package/package.json +72 -4
package/dist/provider.js
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/** Optional PoppyKit provider v1. Hosting, human authentication and key custody belong to the operator. */
|
|
2
|
+
import { createHash, randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
|
|
3
|
+
import { secureUrl } from './network/transport.js';
|
|
4
|
+
export function createResultCipher(key) {
|
|
5
|
+
if (key.length !== 32)
|
|
6
|
+
throw new Error('provider encryption key must be 32 bytes');
|
|
7
|
+
const secret = Buffer.from(key);
|
|
8
|
+
return {
|
|
9
|
+
seal(value) { const iv = randomBytes(12), cipher = createCipheriv('aes-256-gcm', secret, iv); return Buffer.concat([iv, cipher.update(value, 'utf8'), cipher.final(), cipher.getAuthTag()]).toString('base64url'); },
|
|
10
|
+
open(value) { const bytes = Buffer.from(value, 'base64url'), cipher = createDecipheriv('aes-256-gcm', secret, bytes.subarray(0, 12)); cipher.setAuthTag(bytes.subarray(-16)); return Buffer.concat([cipher.update(bytes.subarray(12, -16)), cipher.final()]).toString('utf8'); },
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export class ProviderError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
retryAt;
|
|
16
|
+
constructor(code, retryAt) {
|
|
17
|
+
super(`Provider ${code}`);
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.retryAt = retryAt;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const id = () => randomBytes(32).toString('base64url');
|
|
23
|
+
const hash = (value) => createHash('sha256').update(value).digest('base64url');
|
|
24
|
+
function fail(code) { throw new ProviderError(code); }
|
|
25
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
26
|
+
const text = (value) => typeof value === 'string' && value.length > 0 && value.length <= 2048 ? value : fail('binding_conflict');
|
|
27
|
+
function exact(value, keys) { if (value.version !== 1 || Object.keys(value).some(key => !keys.includes(key)))
|
|
28
|
+
fail('binding_conflict'); }
|
|
29
|
+
function object(value) { if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
30
|
+
fail('binding_conflict'); return value; }
|
|
31
|
+
function https(value) { try {
|
|
32
|
+
return secureUrl(value);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return fail('binding_conflict');
|
|
36
|
+
} }
|
|
37
|
+
function onDomain(value, domain) { const host = https(value).hostname; return host === domain || host.endsWith(`.${domain}`); }
|
|
38
|
+
function publicKey(value) {
|
|
39
|
+
if (value.kty !== 'EC' || value.crv !== 'P-256' || typeof value.x !== 'string' || typeof value.y !== 'string' || 'd' in value || 'k' in value)
|
|
40
|
+
fail('unavailable');
|
|
41
|
+
return { kty: value.kty, crv: value.crv, x: value.x, y: value.y, kid: text(value.kid), alg: 'ES256', use: 'sig' };
|
|
42
|
+
}
|
|
43
|
+
export function createProvider(options) {
|
|
44
|
+
const { store, cipher } = options, now = options.now ?? Date.now;
|
|
45
|
+
const client = https(options.clientId);
|
|
46
|
+
if ([options.callbackUrl, options.jwksUri].some(value => https(value).hostname !== client.hostname) || https(options.callbackUrl).search)
|
|
47
|
+
throw new Error('provider metadata endpoints must share client domain; callback cannot contain query');
|
|
48
|
+
function auth(tx, credential, installationId) {
|
|
49
|
+
const [enrollmentId, secret, extra] = credential.split('.');
|
|
50
|
+
const row = enrollmentId ? tx.get('enrollment', enrollmentId) : undefined;
|
|
51
|
+
if (extra || !row || row.revoked || row.installationId !== installationId || hash(secret ?? '') !== row.verifier)
|
|
52
|
+
fail('unauthorized');
|
|
53
|
+
return { ...row, enrollmentId };
|
|
54
|
+
}
|
|
55
|
+
function operation(tx, enrollment, operationId) {
|
|
56
|
+
const row = tx.get('operation', `${enrollment.enrollmentId}:${operationId}`);
|
|
57
|
+
if (!row)
|
|
58
|
+
fail('binding_conflict');
|
|
59
|
+
if (row.expiresAt <= now())
|
|
60
|
+
fail('expired');
|
|
61
|
+
if (row.status === 'canceled')
|
|
62
|
+
fail('canceled');
|
|
63
|
+
if (row.status === 'consumed')
|
|
64
|
+
fail('consumed');
|
|
65
|
+
return row;
|
|
66
|
+
}
|
|
67
|
+
async function verified(domain, issuer) {
|
|
68
|
+
const metadata = await options.metadata.resolveCompany(domain);
|
|
69
|
+
if (metadata.authorization?.issuer !== issuer)
|
|
70
|
+
fail('binding_conflict');
|
|
71
|
+
return metadata;
|
|
72
|
+
}
|
|
73
|
+
function user(tx, visitor, issuer) {
|
|
74
|
+
const key = hash(JSON.stringify([visitor, issuer])), existing = tx.get('user', key);
|
|
75
|
+
if (existing)
|
|
76
|
+
return existing.userId;
|
|
77
|
+
const userId = id();
|
|
78
|
+
tx.put('user', key, { userId });
|
|
79
|
+
return userId;
|
|
80
|
+
}
|
|
81
|
+
function throttle(tx, enrollment) {
|
|
82
|
+
const starts = enrollment.starts.filter((at) => at > now() - 60000);
|
|
83
|
+
if (starts.length >= 60)
|
|
84
|
+
throw new ProviderError('rate_limited', starts[0] + 60000);
|
|
85
|
+
starts.push(now());
|
|
86
|
+
tx.put('enrollment', enrollment.enrollmentId, { ...enrollment, starts });
|
|
87
|
+
}
|
|
88
|
+
function authorizeCompany(credential, input) {
|
|
89
|
+
const domain = text(input.domain);
|
|
90
|
+
text(input.issuer);
|
|
91
|
+
store.transaction(tx => { const enrollment = auth(tx, credential, text(input.installationId)); if (!enrollment.allowedCompanies.includes(domain))
|
|
92
|
+
fail('unauthorized'); });
|
|
93
|
+
}
|
|
94
|
+
const api = {
|
|
95
|
+
async enroll(request, input) {
|
|
96
|
+
exact(input, ['version', 'installationId', 'idempotencyKey']);
|
|
97
|
+
const human = await options.authenticateEnrollment(request);
|
|
98
|
+
if (!human?.subject || !human.allowedCompanies.length)
|
|
99
|
+
fail('unauthorized');
|
|
100
|
+
const installationId = text(input.installationId), key = hash(JSON.stringify([human.subject, text(input.idempotencyKey)]));
|
|
101
|
+
return store.transaction(tx => {
|
|
102
|
+
if (tx.get('enrollmentRequest', key))
|
|
103
|
+
fail('consumed'); // Credential delivery cannot be replayed from a verifier.
|
|
104
|
+
const subjectKey = hash(human.subject), visitor = tx.get('visitor', subjectKey)?.id ?? id();
|
|
105
|
+
if (tx.list('enrollment', '', (options.maxEnrollments ?? 10000) + 1).length >= (options.maxEnrollments ?? 10000))
|
|
106
|
+
throw new ProviderError('rate_limited', now() + 60000);
|
|
107
|
+
tx.put('visitor', subjectKey, { id: visitor });
|
|
108
|
+
const enrollmentId = id(), secret = id();
|
|
109
|
+
tx.put('enrollment', enrollmentId, { installationId, visitor, verifier: hash(secret), allowedCompanies: [...new Set(human.allowedCompanies)], revoked: false, starts: [] });
|
|
110
|
+
tx.put('enrollmentRequest', key, { enrollmentId });
|
|
111
|
+
return { enrollmentId, credential: `${enrollmentId}.${secret}`, clientId: options.clientId, callbackUrl: options.callbackUrl };
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
async identity(credential, input) {
|
|
115
|
+
exact(input, ['version', 'installationId', 'domain', 'issuer']);
|
|
116
|
+
authorizeCompany(credential, input);
|
|
117
|
+
const metadata = await verified(text(input.domain), text(input.issuer));
|
|
118
|
+
return store.transaction(tx => {
|
|
119
|
+
const enrollment = auth(tx, credential, text(input.installationId));
|
|
120
|
+
if (!enrollment.allowedCompanies.includes(metadata.document.organization.domain))
|
|
121
|
+
fail('unauthorized');
|
|
122
|
+
return { clientId: options.clientId, userId: user(tx, enrollment.visitor, input.issuer) };
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
async begin(credential, input) {
|
|
126
|
+
exact(input, ['version', 'installationId', 'operationId', 'domain', 'issuer', 'purpose', 'sessionId', 'returnTo', 'state']);
|
|
127
|
+
if (!['client-authentication', 'session', 'browser', 'direct'].includes(input.purpose))
|
|
128
|
+
fail('binding_conflict');
|
|
129
|
+
text(input.operationId);
|
|
130
|
+
if (input.sessionId !== undefined)
|
|
131
|
+
text(input.sessionId);
|
|
132
|
+
authorizeCompany(credential, input);
|
|
133
|
+
if (input.purpose === 'client-authentication' && (input.sessionId !== undefined || input.returnTo !== undefined))
|
|
134
|
+
fail('binding_conflict');
|
|
135
|
+
if (input.purpose !== 'browser' && input.returnTo !== undefined)
|
|
136
|
+
fail('binding_conflict');
|
|
137
|
+
if (input.purpose !== 'direct' && input.state !== undefined)
|
|
138
|
+
fail('binding_conflict');
|
|
139
|
+
if (input.purpose === 'direct' && (typeof input.state !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(input.state) || !input.sessionId))
|
|
140
|
+
fail('binding_conflict');
|
|
141
|
+
if (input.purpose === 'browser' && !input.sessionId)
|
|
142
|
+
fail('binding_conflict');
|
|
143
|
+
if (input.purpose === 'browser')
|
|
144
|
+
text(input.returnTo);
|
|
145
|
+
const metadata = await verified(text(input.domain), text(input.issuer));
|
|
146
|
+
if (input.purpose === 'browser' && (!onDomain(input.returnTo, metadata.document.organization.domain) || !metadata.document.web?.browser_session_endpoint))
|
|
147
|
+
fail('binding_conflict');
|
|
148
|
+
const identity = store.transaction(tx => {
|
|
149
|
+
const enrollment = auth(tx, credential, text(input.installationId));
|
|
150
|
+
if (!enrollment.allowedCompanies.includes(metadata.document.organization.domain))
|
|
151
|
+
fail('unauthorized');
|
|
152
|
+
return { enrollment, userId: user(tx, enrollment.visitor, input.issuer) };
|
|
153
|
+
});
|
|
154
|
+
if (input.purpose === 'browser' && !await options.verifySession?.({ clientId: options.clientId, userId: identity.userId, issuer: input.issuer, sessionId: input.sessionId }))
|
|
155
|
+
fail('unauthorized');
|
|
156
|
+
const binding = { issuer: input.issuer, domain: metadata.document.organization.domain, purpose: input.purpose, sessionId: input.sessionId ?? null, returnTo: input.returnTo ?? null, stateHash: input.state ? hash(input.state) : null, userId: identity.userId, browserEndpoint: metadata.document.web?.browser_session_endpoint ?? null };
|
|
157
|
+
return store.transaction(tx => {
|
|
158
|
+
const enrollment = auth(tx, credential, text(input.installationId)), key = `${enrollment.enrollmentId}:${text(input.operationId)}`, existing = tx.get('operation', key);
|
|
159
|
+
if (existing) {
|
|
160
|
+
if (!same(existing.binding, binding))
|
|
161
|
+
fail('binding_conflict');
|
|
162
|
+
operation(tx, enrollment, input.operationId);
|
|
163
|
+
return { expiresAt: existing.expiresAt };
|
|
164
|
+
}
|
|
165
|
+
const rows = tx.list('operation', `${enrollment.enrollmentId}:`, 1001);
|
|
166
|
+
// Bounded cleanup preserves tombstones until expiry plus clock tolerance.
|
|
167
|
+
for (const row of rows)
|
|
168
|
+
if (row.value.expiresAt + 30000 <= now()) {
|
|
169
|
+
tx.remove('operation', row.id);
|
|
170
|
+
if (row.value.binding.stateHash)
|
|
171
|
+
tx.remove('state', row.value.binding.stateHash);
|
|
172
|
+
}
|
|
173
|
+
// A hard retained-row cap keeps every quota/revocation scan complete, including tombstones.
|
|
174
|
+
if (rows.filter(row => row.value.expiresAt + 30000 > now()).length >= 1000)
|
|
175
|
+
throw new ProviderError('rate_limited', now() + 60000);
|
|
176
|
+
if (rows.filter(row => row.value.expiresAt > now() && ['pending', 'ready'].includes(row.value.status)).length >= 10)
|
|
177
|
+
throw new ProviderError('rate_limited', now() + 60000);
|
|
178
|
+
const expiresAt = now() + 600000;
|
|
179
|
+
if (binding.stateHash && tx.get('state', binding.stateHash))
|
|
180
|
+
fail('binding_conflict');
|
|
181
|
+
tx.put('operation', key, { binding, expiresAt, status: 'pending', result: null, resultHash: null });
|
|
182
|
+
if (binding.stateHash)
|
|
183
|
+
tx.put('state', binding.stateHash, { enrollmentId: enrollment.enrollmentId, operationId: input.operationId });
|
|
184
|
+
return { expiresAt };
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
async assert(credential, input) {
|
|
188
|
+
exact(input, ['version', 'installationId', 'operationId', 'purpose']);
|
|
189
|
+
const snapshot = store.transaction(tx => {
|
|
190
|
+
const enrollment = auth(tx, credential, text(input.installationId)), op = operation(tx, enrollment, text(input.operationId));
|
|
191
|
+
if (op.binding.purpose !== input.purpose || !['client-authentication', 'session', 'browser'].includes(input.purpose))
|
|
192
|
+
fail('binding_conflict');
|
|
193
|
+
throttle(tx, enrollment);
|
|
194
|
+
return { enrollment, op };
|
|
195
|
+
});
|
|
196
|
+
const b = snapshot.op.binding, metadata = await verified(b.domain, b.issuer);
|
|
197
|
+
if (b.browserEndpoint !== (metadata.document.web?.browser_session_endpoint ?? null))
|
|
198
|
+
fail('binding_conflict');
|
|
199
|
+
if (b.purpose === 'browser' && !await options.verifySession?.({ clientId: options.clientId, userId: b.userId, issuer: b.issuer, sessionId: b.sessionId }))
|
|
200
|
+
fail('unauthorized');
|
|
201
|
+
const iat = Math.floor(now() / 1000), exp = iat + 60;
|
|
202
|
+
const claims = { iss: options.clientId, sub: b.purpose === 'client-authentication' ? options.clientId : b.userId, aud: b.purpose === 'browser' ? b.browserEndpoint : b.issuer, iat, exp, jti: id(), ...(b.purpose === 'browser' ? { session_id: b.sessionId, return_to: b.returnTo } : {}) };
|
|
203
|
+
const header = { alg: 'ES256', kid: options.signer.kid, typ: b.purpose === 'client-authentication' ? 'client-authentication+jwt' : b.purpose === 'browser' ? 'poppy-browser+jwt' : 'JWT' };
|
|
204
|
+
const unsigned = `${Buffer.from(JSON.stringify(header)).toString('base64url')}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}`;
|
|
205
|
+
const signature = await options.signer.sign(new TextEncoder().encode(unsigned));
|
|
206
|
+
if (signature.length !== 64)
|
|
207
|
+
fail('unavailable');
|
|
208
|
+
// A revoke/cancel racing external KMS signing wins before the assertion leaves the provider.
|
|
209
|
+
store.transaction(tx => { const enrollment = auth(tx, credential, input.installationId), op = operation(tx, enrollment, input.operationId); tx.put('operation', `${enrollment.enrollmentId}:${input.operationId}`, { ...op, status: 'signed' }); });
|
|
210
|
+
return { assertion: `${unsigned}.${Buffer.from(signature).toString('base64url')}`, expiresAt: exp };
|
|
211
|
+
},
|
|
212
|
+
callback(callbackUrl) {
|
|
213
|
+
const url = new URL(callbackUrl), base = new URL(options.callbackUrl);
|
|
214
|
+
if (url.origin !== base.origin || url.pathname !== base.pathname || url.username || url.password || url.hash || [...url.searchParams.keys()].some(key => !['state', 'iss', 'code', 'error'].includes(key)) || ['state', 'iss', 'code', 'error'].some(key => url.searchParams.getAll(key).length > 1))
|
|
215
|
+
fail('binding_conflict');
|
|
216
|
+
const state = text(url.searchParams.get('state')), issuer = text(url.searchParams.get('iss'));
|
|
217
|
+
if (!!url.searchParams.get('code') === !!url.searchParams.get('error') || callbackUrl.length > 8192)
|
|
218
|
+
fail('binding_conflict');
|
|
219
|
+
return store.transaction(tx => {
|
|
220
|
+
const lookup = tx.get('state', hash(state));
|
|
221
|
+
if (!lookup)
|
|
222
|
+
fail('binding_conflict');
|
|
223
|
+
const enrollment = tx.get('enrollment', lookup.enrollmentId);
|
|
224
|
+
if (!enrollment || enrollment.revoked)
|
|
225
|
+
fail('unauthorized');
|
|
226
|
+
const op = operation(tx, { enrollmentId: lookup.enrollmentId }, lookup.operationId);
|
|
227
|
+
if (op.binding.issuer !== issuer || op.binding.purpose !== 'direct')
|
|
228
|
+
fail('binding_conflict');
|
|
229
|
+
const fingerprint = hash(callbackUrl);
|
|
230
|
+
if (op.status === 'ready') {
|
|
231
|
+
if (op.resultHash !== fingerprint)
|
|
232
|
+
fail('binding_conflict');
|
|
233
|
+
return { status: 'ready' };
|
|
234
|
+
}
|
|
235
|
+
tx.put('operation', `${lookup.enrollmentId}:${lookup.operationId}`, { ...op, status: 'ready', result: cipher.seal(callbackUrl), resultHash: fingerprint });
|
|
236
|
+
return { status: 'ready' };
|
|
237
|
+
});
|
|
238
|
+
},
|
|
239
|
+
takeResult(credential, input) {
|
|
240
|
+
exact(input, ['version', 'installationId', 'operationId', 'issuer', 'sessionId']);
|
|
241
|
+
return store.transaction(tx => {
|
|
242
|
+
const enrollment = auth(tx, credential, text(input.installationId)), op = operation(tx, enrollment, text(input.operationId));
|
|
243
|
+
if (op.binding.issuer !== input.issuer || op.binding.sessionId !== input.sessionId || op.binding.purpose !== 'direct')
|
|
244
|
+
fail('binding_conflict');
|
|
245
|
+
if (op.status !== 'ready')
|
|
246
|
+
return { status: 'pending' };
|
|
247
|
+
const callbackUrl = cipher.open(op.result);
|
|
248
|
+
tx.put('operation', `${enrollment.enrollmentId}:${input.operationId}`, { ...op, status: 'consumed', result: null });
|
|
249
|
+
return { status: 'completed', callbackUrl };
|
|
250
|
+
});
|
|
251
|
+
},
|
|
252
|
+
cancel(credential, input) {
|
|
253
|
+
exact(input, ['version', 'installationId', 'operationId']);
|
|
254
|
+
return store.transaction(tx => {
|
|
255
|
+
const enrollment = auth(tx, credential, text(input.installationId)), key = `${enrollment.enrollmentId}:${text(input.operationId)}`, op = tx.get('operation', key);
|
|
256
|
+
if (!op)
|
|
257
|
+
fail('binding_conflict');
|
|
258
|
+
tx.put('operation', key, { ...op, status: 'canceled', result: null });
|
|
259
|
+
return { status: 'canceled' };
|
|
260
|
+
});
|
|
261
|
+
},
|
|
262
|
+
revokeEnrollment(credential, input) {
|
|
263
|
+
exact(input, ['version', 'installationId']);
|
|
264
|
+
return store.transaction(tx => {
|
|
265
|
+
const enrollment = auth(tx, credential, text(input.installationId));
|
|
266
|
+
tx.put('enrollment', enrollment.enrollmentId, { ...enrollment, revoked: true });
|
|
267
|
+
for (const row of tx.list('operation', `${enrollment.enrollmentId}:`, 1000))
|
|
268
|
+
tx.put('operation', row.id, { ...row.value, status: 'canceled', result: null });
|
|
269
|
+
return { status: 'revoked' };
|
|
270
|
+
});
|
|
271
|
+
},
|
|
272
|
+
async handle(request) {
|
|
273
|
+
const headers = { 'cache-control': 'no-store', 'content-type': 'application/json' };
|
|
274
|
+
try {
|
|
275
|
+
if (request.url === options.clientId && request.method === 'GET')
|
|
276
|
+
return Response.json({ client_id: options.clientId, client_name: options.clientName, jwks_uri: options.jwksUri, redirect_uris: [options.callbackUrl], token_endpoint_auth_method: 'private_key_jwt' }, { headers });
|
|
277
|
+
if (request.url === options.jwksUri && request.method === 'GET')
|
|
278
|
+
return Response.json({ keys: (options.publishedKeys ? await options.publishedKeys() : [{ ...await options.signer.publicJwk(), kid: options.signer.kid }]).map(publicKey) }, { headers });
|
|
279
|
+
const url = new URL(request.url);
|
|
280
|
+
if (url.origin === new URL(options.callbackUrl).origin && url.pathname === new URL(options.callbackUrl).pathname && request.method === 'GET') {
|
|
281
|
+
api.callback(request.url);
|
|
282
|
+
return Response.json({ status: 'received' }, { headers });
|
|
283
|
+
}
|
|
284
|
+
if (request.method !== 'POST' || !request.headers.get('content-type')?.startsWith('application/json'))
|
|
285
|
+
fail('binding_conflict');
|
|
286
|
+
const reader = request.body?.getReader();
|
|
287
|
+
let body = '', bytes = 0;
|
|
288
|
+
if (reader)
|
|
289
|
+
try {
|
|
290
|
+
for (;;) {
|
|
291
|
+
const part = await reader.read();
|
|
292
|
+
if (part.done)
|
|
293
|
+
break;
|
|
294
|
+
bytes += part.value.length;
|
|
295
|
+
if (bytes > 16384)
|
|
296
|
+
fail('binding_conflict');
|
|
297
|
+
body += new TextDecoder().decode(part.value, { stream: true });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
await reader.cancel().catch(() => { });
|
|
302
|
+
}
|
|
303
|
+
const input = object(JSON.parse(body));
|
|
304
|
+
if (input.version !== 1)
|
|
305
|
+
fail('binding_conflict');
|
|
306
|
+
const action = url.pathname.slice(url.pathname.lastIndexOf('/') + 1), credential = request.headers.get('authorization')?.match(/^Bearer (\S+)$/)?.[1] ?? '';
|
|
307
|
+
const allowed = ['enroll', 'identity', 'begin', 'assert', 'takeResult', 'cancel', 'revokeEnrollment'];
|
|
308
|
+
if (!allowed.includes(action))
|
|
309
|
+
fail('binding_conflict');
|
|
310
|
+
const result = action === 'enroll' ? await api.enroll(request, input) : await api[action](credential, input);
|
|
311
|
+
return Response.json(result, { headers });
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
const code = error instanceof ProviderError ? error.code : 'unavailable';
|
|
315
|
+
return Response.json({ error: code, ...(error instanceof ProviderError && error.retryAt ? { retryAt: error.retryAt } : {}) }, { status: code === 'unauthorized' ? 401 : code === 'rate_limited' ? 429 : code === 'unavailable' ? 503 : 409, headers });
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
return api;
|
|
320
|
+
}
|
|
321
|
+
/** Bound trusted adapter: credentials/assertions/results never belong in harness output. */
|
|
322
|
+
export function createProviderClient(options) {
|
|
323
|
+
const base = https(options.url);
|
|
324
|
+
if (base.search || base.hash)
|
|
325
|
+
throw new Error('provider URL must not contain query/fragment');
|
|
326
|
+
async function call(action, input) {
|
|
327
|
+
const response = await options.transport.fetch(new Request(`${options.url.replace(/\/$/, '')}/${action}`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${await options.credential()}` }, body: JSON.stringify({ version: 1, installationId: options.installationId, ...input }) }));
|
|
328
|
+
const body = object(await response.json());
|
|
329
|
+
if (!response.ok)
|
|
330
|
+
throw new ProviderError(['unauthorized', 'binding_conflict', 'expired', 'consumed', 'canceled', 'rate_limited'].includes(body.error) ? body.error : 'unavailable', body.retryAt);
|
|
331
|
+
return body;
|
|
332
|
+
}
|
|
333
|
+
const identity = async (issuer) => { const value = await call('identity', { issuer, domain: options.domain }); return { clientId: text(value.clientId), userId: text(value.userId) }; };
|
|
334
|
+
const assert = async (request) => {
|
|
335
|
+
await call('begin', { ...request, domain: options.domain });
|
|
336
|
+
const value = await call('assert', { operationId: request.operationId, purpose: request.purpose });
|
|
337
|
+
return { assertion: text(value.assertion), expiresAt: value.expiresAt };
|
|
338
|
+
};
|
|
339
|
+
return { identity, assert, beginDirect: (input) => call('begin', { ...input, domain: options.domain, purpose: 'direct' }),
|
|
340
|
+
takeResult: (input) => call('takeResult', input),
|
|
341
|
+
cancel: (operationId) => call('cancel', { operationId }), revokeEnrollment: () => call('revokeEnrollment', {}) };
|
|
342
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/sqlite.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { SqliteAuthStore } from './auth/sqlite-store.js';
|
|
2
|
+
import type { ConversationKey, ConversationStore } from './conversation-store.js';
|
|
3
|
+
import type { ConsumerCheckpoint, OutboundRecord } from './conversation.js';
|
|
4
|
+
/** Optional durable adapter. One local SQLite database; not a secret vault. */
|
|
5
|
+
export declare class SqliteConversationStore implements ConversationStore {
|
|
6
|
+
private readonly db;
|
|
7
|
+
constructor(path: string);
|
|
8
|
+
close(): void;
|
|
9
|
+
/** Explicit local administration only; forgetting a retry does not cancel accepted remote work. */
|
|
10
|
+
removeOutbound(key: ConversationKey, id: string): void;
|
|
11
|
+
private atomic;
|
|
12
|
+
private outbound;
|
|
13
|
+
getOutbound(key: ConversationKey, id: string): Promise<OutboundRecord | undefined>;
|
|
14
|
+
prepareOutbound(key: ConversationKey, record: OutboundRecord): Promise<OutboundRecord>;
|
|
15
|
+
settleOutbound(key: ConversationKey, id: string, result: Pick<OutboundRecord, 'phase' | 'response' | 'error'>): Promise<void>;
|
|
16
|
+
private checkpoint;
|
|
17
|
+
getCheckpoint(key: ConversationKey, conversation: string, name: string): Promise<string | undefined>;
|
|
18
|
+
advanceCheckpoint(key: ConversationKey, row: ConsumerCheckpoint, from: readonly (string | undefined)[]): Promise<void>;
|
|
19
|
+
resetCheckpoint(key: ConversationKey, conversation: string, name: string): Promise<void>;
|
|
20
|
+
}
|
package/dist/sqlite.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { initializeSqlite } from './sqlite-init.js';
|
|
3
|
+
export { SqliteAuthStore } from './auth/sqlite-store.js';
|
|
4
|
+
/** Optional durable adapter. One local SQLite database; not a secret vault. */
|
|
5
|
+
export class SqliteConversationStore {
|
|
6
|
+
db;
|
|
7
|
+
constructor(path) {
|
|
8
|
+
this.db = new DatabaseSync(path);
|
|
9
|
+
initializeSqlite(this.db, () => this.db.exec(`PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;
|
|
10
|
+
CREATE TABLE IF NOT EXISTS outbound (
|
|
11
|
+
connection TEXT NOT NULL, issuer TEXT NOT NULL, id TEXT NOT NULL, body TEXT NOT NULL,
|
|
12
|
+
PRIMARY KEY(connection, issuer, id));
|
|
13
|
+
CREATE TABLE IF NOT EXISTS checkpoints (
|
|
14
|
+
connection TEXT NOT NULL, issuer TEXT NOT NULL, endpoint TEXT NOT NULL,
|
|
15
|
+
conversation TEXT NOT NULL, name TEXT NOT NULL, cursor TEXT NOT NULL,
|
|
16
|
+
PRIMARY KEY(connection, issuer, endpoint, conversation, name));`));
|
|
17
|
+
}
|
|
18
|
+
close() { this.db.close(); }
|
|
19
|
+
/** Explicit local administration only; forgetting a retry does not cancel accepted remote work. */
|
|
20
|
+
removeOutbound(key, id) {
|
|
21
|
+
this.atomic(() => {
|
|
22
|
+
const record = this.outbound(key, id);
|
|
23
|
+
if (record && record.endpoint !== key.endpoint)
|
|
24
|
+
throw new TypeError('outbound binding');
|
|
25
|
+
this.db.prepare('DELETE FROM outbound WHERE connection=? AND issuer=? AND id=?').run(key.connectionId, key.issuer, id);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
atomic(action) {
|
|
29
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
30
|
+
try {
|
|
31
|
+
const result = action();
|
|
32
|
+
this.db.exec('COMMIT');
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
this.db.exec('ROLLBACK');
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
outbound(key, id) {
|
|
41
|
+
const row = this.db.prepare('SELECT body FROM outbound WHERE connection=? AND issuer=? AND id=?').get(key.connectionId, key.issuer, id);
|
|
42
|
+
return row ? JSON.parse(row.body) : undefined;
|
|
43
|
+
}
|
|
44
|
+
async getOutbound(key, id) { return this.outbound(key, id); }
|
|
45
|
+
async prepareOutbound(key, record) {
|
|
46
|
+
if (record.connectionId !== key.connectionId || record.issuer !== key.issuer || record.endpoint !== key.endpoint)
|
|
47
|
+
throw new TypeError('outbound binding');
|
|
48
|
+
return this.atomic(() => {
|
|
49
|
+
this.db.prepare('INSERT OR IGNORE INTO outbound VALUES (?,?,?,?)').run(key.connectionId, key.issuer, record.id, JSON.stringify(record));
|
|
50
|
+
return this.outbound(key, record.id);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
async settleOutbound(key, id, result) {
|
|
54
|
+
this.atomic(() => {
|
|
55
|
+
const record = this.outbound(key, id);
|
|
56
|
+
if (!record || record.endpoint !== key.endpoint)
|
|
57
|
+
throw new TypeError('outbound binding');
|
|
58
|
+
if (record.phase !== 'pending')
|
|
59
|
+
return;
|
|
60
|
+
this.db.prepare('UPDATE outbound SET body=? WHERE connection=? AND issuer=? AND id=?').run(JSON.stringify({ ...record, ...result }), key.connectionId, key.issuer, id);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
checkpoint(key, conversation, name) {
|
|
64
|
+
return this.db.prepare('SELECT cursor FROM checkpoints WHERE connection=? AND issuer=? AND endpoint=? AND conversation=? AND name=?')
|
|
65
|
+
.get(key.connectionId, key.issuer, key.endpoint, conversation, name)?.cursor;
|
|
66
|
+
}
|
|
67
|
+
async getCheckpoint(key, conversation, name) { return this.checkpoint(key, conversation, name); }
|
|
68
|
+
async advanceCheckpoint(key, row, from) {
|
|
69
|
+
if (row.connectionId !== key.connectionId || row.issuer !== key.issuer)
|
|
70
|
+
throw new TypeError('checkpoint binding');
|
|
71
|
+
this.atomic(() => {
|
|
72
|
+
if (!from.includes(this.checkpoint(key, row.conversationId, row.name)))
|
|
73
|
+
return;
|
|
74
|
+
this.db.prepare('INSERT OR REPLACE INTO checkpoints VALUES (?,?,?,?,?,?)').run(key.connectionId, key.issuer, key.endpoint, row.conversationId, row.name, row.cursor);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
async resetCheckpoint(key, conversation, name) {
|
|
78
|
+
this.db.prepare('DELETE FROM checkpoints WHERE connection=? AND issuer=? AND endpoint=? AND conversation=? AND name=?').run(key.connectionId, key.issuer, key.endpoint, conversation, name);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { createMetadataResolver, createNodeTransport } from '@poppykit/client';
|
|
3
|
+
|
|
4
|
+
const selfCheck = process.argv.includes('--self-check');
|
|
5
|
+
const domain = selfCheck ? 'company.example' : process.argv[2];
|
|
6
|
+
if (!domain) throw new Error('usage: node examples/quickstart.mjs DOMAIN|--self-check');
|
|
7
|
+
const transport = selfCheck ? {
|
|
8
|
+
async fetch(request) {
|
|
9
|
+
assert.equal(request.url, 'https://company.example/.well-known/poppy.json');
|
|
10
|
+
return Response.json({ protocol_version: '0.2', organization: { name: 'Example Company', domain }, web: {} });
|
|
11
|
+
},
|
|
12
|
+
} : createNodeTransport();
|
|
13
|
+
try {
|
|
14
|
+
const company = await createMetadataResolver(transport).resolveCompany(domain);
|
|
15
|
+
if (selfCheck) assert.equal(company.document.organization.name, 'Example Company');
|
|
16
|
+
console.log(company.document.organization);
|
|
17
|
+
} finally {
|
|
18
|
+
transport.close?.();
|
|
19
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,74 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poppykit/client",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Personal Agent Protocol client SDK",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/LMol-4/poppykit-client.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
|
+
"PROVIDER.md",
|
|
36
|
+
"INTEGRATIONS.md",
|
|
37
|
+
"API.md",
|
|
38
|
+
"CONTRIBUTING.md",
|
|
39
|
+
"SECURITY.md",
|
|
40
|
+
"CHANGELOG.md",
|
|
41
|
+
"RELEASING.md",
|
|
42
|
+
"examples",
|
|
43
|
+
"LICENSE"
|
|
44
|
+
],
|
|
45
|
+
"exports": {
|
|
46
|
+
".": {
|
|
47
|
+
"types": "./dist/index.d.ts",
|
|
48
|
+
"import": "./dist/index.js"
|
|
49
|
+
},
|
|
50
|
+
"./sqlite": {
|
|
51
|
+
"types": "./dist/sqlite.d.ts",
|
|
52
|
+
"import": "./dist/sqlite.js"
|
|
53
|
+
},
|
|
54
|
+
"./memory": {
|
|
55
|
+
"types": "./dist/memory.d.ts",
|
|
56
|
+
"import": "./dist/memory.js"
|
|
57
|
+
},
|
|
58
|
+
"./provider": {
|
|
59
|
+
"import": "./dist/provider.js",
|
|
60
|
+
"types": "./dist/provider.d.ts"
|
|
61
|
+
},
|
|
62
|
+
"./provider/sqlite": {
|
|
63
|
+
"import": "./dist/provider-sqlite.js",
|
|
64
|
+
"types": "./dist/provider-sqlite.d.ts"
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"dependencies": {
|
|
68
|
+
"jose": "6.2.12"
|
|
69
|
+
},
|
|
70
|
+
"license": "MIT",
|
|
71
|
+
"publishConfig": {
|
|
72
|
+
"access": "public"
|
|
73
|
+
}
|
|
74
|
+
}
|