@reclaimprotocol/client 0.1.0-dev.1 → 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.
package/src/reclaim.ts ADDED
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Declarative entry point for the consumer SDK. The ONLY config is the org
3
+ * secret (`rorg_…`) — it authenticates every call and identifies the org
4
+ * server-side. Everything else is passed where it's actually used: the `orgId`
5
+ * to the org-scoped `credential.set`/`get`, and the X25519 decryption key to
6
+ * `results.decrypt`/`receive` (only when results arrive encrypted):
7
+ *
8
+ * const reclaim = createReclaim({ orgSecret })
9
+ * // one-time setup:
10
+ * await reclaim.callbacks.register({ callbackUrl })
11
+ * // enable encryption:
12
+ * await reclaim.credential.set(orgId, { publicKeyJwk })
13
+ * const session = await reclaim.sessions.create({ providerId, context })
14
+ * // …end-user completes session.verificationUrl…
15
+ * const outcome = await reclaim.results.receive(callbackBody, {
16
+ * credential: privateJwk, // omit for plaintext
17
+ * expectedReclaimSessionId: session.id,
18
+ * })
19
+ *
20
+ * Built by composition over the package's pure helpers (`decryptCallback`,
21
+ * `verifyResultFull`, …) — drop down to those, or to `reclaim.api`, for full
22
+ * control.
23
+ */
24
+ import type { components } from './types/openapi.gen.ts'
25
+ import { ReclaimClient } from './client.ts'
26
+ import {
27
+ type DecryptionKey,
28
+ type DecryptionKeyInput,
29
+ loadDecryptionKey,
30
+ publicPemToJwk,
31
+ } from './enckeys.ts'
32
+ import {
33
+ type CallbackBody,
34
+ decryptCallback,
35
+ type Jws,
36
+ verifyResultFull,
37
+ type VerifyResultFullOptions,
38
+ type VerifyResultFullOutcome,
39
+ } from './result.ts'
40
+
41
+ type CallbackSubscription = components['schemas']['CallbackSubscription']
42
+ type VerificationSession = components['schemas']['VerificationSession']
43
+ type VerificationEvent = components['schemas']['VerificationEvent']
44
+ type VerificationEventRecord = components['schemas']['VerificationEventRecord']
45
+ type OrgCredential = components['schemas']['OrgCredential']
46
+ type JwkX25519Public = components['schemas']['JwkX25519Public']
47
+
48
+ /** The three terminal events a subscription can receive. */
49
+ export const TERMINAL_EVENTS = [
50
+ 'verification_success',
51
+ 'verification_rejected',
52
+ 'verification_error',
53
+ ] as const
54
+
55
+ export interface RegisterCallbackInput {
56
+ callbackUrl: string
57
+ events: VerificationEvent[]
58
+ }
59
+
60
+ export interface CreateSessionInput {
61
+ providerId: string
62
+ version?: string
63
+ context: Record<string, unknown>
64
+ /** URL with a `{sessionId}` placeholder the end-user opens. */
65
+ verificationClientUrl: string
66
+ }
67
+
68
+ export interface SetCredentialInput {
69
+ /** The org's X25519 encryption public key as an SPKI PEM — the content of
70
+ * `openssl pkey -in key.pem -pubout` (the `-----BEGIN PUBLIC KEY-----`
71
+ * block). It's converted to a JWK and stored; results are JWE-encrypted to
72
+ * it. Keep the matching private PEM to decrypt (pass it to
73
+ * `results.receive`/`decrypt`). */
74
+ publicKeyPem: string
75
+ label?: string
76
+ }
77
+
78
+ export interface ReclaimConfig {
79
+ /** The org secret (`rorg_…`). Authenticates ALL API calls and identifies
80
+ * the org server-side. The only required config. */
81
+ orgSecret: string
82
+ /** Backend base URL. Defaults to the production Builder. */
83
+ baseUrl?: string
84
+ /** Custom fetch implementation. */
85
+ fetch?: typeof fetch
86
+ /** Extra headers merged into every request. */
87
+ headers?: Record<string, string>
88
+ }
89
+
90
+ /** Options for `results.receive` — the verification options plus an optional
91
+ * X25519 decryption key (a JWK, a PKCS#8 PEM string, or `{ env }` / env-var
92
+ * name). The key is needed only when the delivery is an encrypted (JWE). */
93
+ export type ReceiveOptions = VerifyResultFullOptions & {
94
+ credential?: DecryptionKeyInput
95
+ }
96
+
97
+ export interface Reclaim {
98
+ /** Typed escape hatch: `reclaim.api('OperationId', args)` for any spec
99
+ * operation. */
100
+ api: ReclaimClient['call']
101
+ sessions: {
102
+ /** Create a verification session. `verificationClientUrl` defaults to
103
+ * the Builder's own `/v/{sessionId}` page. */
104
+ create(
105
+ input: Omit<CreateSessionInput, 'verificationClientUrl'> &
106
+ Partial<Pick<CreateSessionInput, 'verificationClientUrl'>>,
107
+ ): Promise<VerificationSession>
108
+ get(sessionId: string): Promise<VerificationSession>
109
+ listEvents(sessionId: string): Promise<VerificationEventRecord[]>
110
+ }
111
+ callbacks: {
112
+ /** Register a callback subscription (one-time setup). Delivery is scoped
113
+ * to the token's org; results are JWE-encrypted to the org's credential
114
+ * when one is set (see `credential.set`), else plaintext. `events`
115
+ * defaults to all three terminal events. */
116
+ register(
117
+ input: Partial<RegisterCallbackInput> &
118
+ Pick<RegisterCallbackInput, 'callbackUrl'>,
119
+ ): Promise<CallbackSubscription>
120
+ list(): Promise<CallbackSubscription[]>
121
+ remove(subscriptionId: string): Promise<void>
122
+ }
123
+ credential: {
124
+ /** Set/replace org `orgId`'s single X25519 encryption credential. Once
125
+ * set, terminal results are delivered as a compact JWE encrypted to it. */
126
+ set(orgId: string, input: SetCredentialInput): Promise<OrgCredential>
127
+ /** Get org `orgId`'s encryption credential, or `undefined` when none is
128
+ * set (delivery is plaintext). */
129
+ get(orgId: string): Promise<OrgCredential | undefined>
130
+ }
131
+ results: {
132
+ /** Decrypt a callback body (JWE or plaintext JWS) to the signed JWS.
133
+ * Pass the X25519 `credential` (JWK/PEM/env) only when the delivery is
134
+ * a JWE. */
135
+ decrypt(body: CallbackBody, credential?: DecryptionKeyInput): Promise<Jws>
136
+ /** Verify a decrypted JWS: JWKS discovery, ES256K signature,
137
+ * `reclaimSessionId`/`aud`/`exp`, nested attestor proofs. */
138
+ verify(
139
+ jws: Jws,
140
+ opts?: VerifyResultFullOptions,
141
+ ): Promise<VerifyResultFullOutcome>
142
+ /** Handle any callback delivery in one call — the 90% path. Branches on
143
+ * the envelope's `event`: a terminal event is decrypted (with
144
+ * `opts.credential` when encrypted) + verified into a `result`; a
145
+ * non-terminal event is returned as a plaintext (unsigned) `event`
146
+ * notification without decryption. (A raw JWE/JWS body with no envelope
147
+ * `event` is treated as a terminal result.) */
148
+ receive(
149
+ body: CallbackBody,
150
+ opts?: ReceiveOptions,
151
+ ): Promise<ReceivedDelivery>
152
+ }
153
+ }
154
+
155
+ /** Outcome of `results.receive` — a verified terminal result or a plaintext
156
+ * non-terminal progress notification. Branch on `kind`. */
157
+ export type ReceivedDelivery =
158
+ | { kind: 'result', result: VerifyResultFullOutcome }
159
+ | {
160
+ kind: 'event'
161
+ event: VerificationEvent
162
+ eventData: Record<string, unknown>
163
+ }
164
+
165
+ export function createReclaim(config: ReclaimConfig): Reclaim {
166
+ if(!config?.orgSecret) {
167
+ throw new Error(
168
+ 'createReclaim requires an org secret — pass '
169
+ + 'createReclaim({ orgSecret: <rorg_… org secret> })',
170
+ )
171
+ }
172
+
173
+ const client = new ReclaimClient({
174
+ baseUrl: config.baseUrl,
175
+ token: config.orgSecret,
176
+ fetch: config.fetch,
177
+ headers: config.headers,
178
+ })
179
+
180
+ // Load an X25519 decryption key on demand — `undefined` input ⇒ no key, so
181
+ // plaintext-only consumers never touch the loader.
182
+ const resolveKey = (
183
+ input: DecryptionKeyInput | undefined,
184
+ ): Promise<DecryptionKey | undefined> => (
185
+ input === undefined
186
+ ? Promise.resolve(undefined)
187
+ : loadDecryptionKey(input)
188
+ )
189
+
190
+ return {
191
+ api: client.call.bind(client),
192
+ sessions: {
193
+ async create(input) {
194
+ const body: CreateSessionInput = {
195
+ ...input,
196
+ verificationClientUrl:
197
+ input.verificationClientUrl ?? `${client.baseUrl}/v/{sessionId}`,
198
+ }
199
+ const { data } = await client.call('CreateVerificationSession', {
200
+ body,
201
+ })
202
+ return data
203
+ },
204
+ async get(sessionId) {
205
+ const { data } = await client.call('GetVerificationSession', {
206
+ params: { sessionId },
207
+ })
208
+ return data
209
+ },
210
+ async listEvents(sessionId) {
211
+ const { data } = await client.call('ListVerificationEvents', {
212
+ params: { sessionId },
213
+ })
214
+ return data.items
215
+ },
216
+ },
217
+ callbacks: {
218
+ async register(input) {
219
+ const events = input.events ?? [...TERMINAL_EVENTS]
220
+ const { data } = await client.call('CreateCallbackSubscription', {
221
+ body: { callbackUrl: input.callbackUrl, events },
222
+ })
223
+ return data
224
+ },
225
+ async list() {
226
+ const { data } = await client.call('ListCallbackSubscriptions')
227
+ return data.items
228
+ },
229
+ async remove(subscriptionId) {
230
+ await client.call('DeleteCallbackSubscription', {
231
+ params: { subscriptionId },
232
+ })
233
+ },
234
+ },
235
+ credential: {
236
+ async set(orgId, input) {
237
+ // Convert the consumer's SPKI public PEM to the JWK the API stores
238
+ // (PEM at the edge; JWK on the wire/DB/VC stays unchanged).
239
+ const publicKeyJwk = (await publicPemToJwk(
240
+ input.publicKeyPem,
241
+ )) as JwkX25519Public
242
+ const { data } = await client.call('SetOrgCredential', {
243
+ params: { orgId },
244
+ body: { publicKeyJwk, label: input.label },
245
+ })
246
+ return data
247
+ },
248
+ async get(orgId) {
249
+ try {
250
+ const { data } = await client.call('GetOrgCredential', {
251
+ params: { orgId },
252
+ })
253
+ return data
254
+ } catch(err) {
255
+ // 404 ⇒ no credential set (plaintext delivery).
256
+ if(isNotFound(err)) {
257
+ return undefined
258
+ }
259
+
260
+ throw err
261
+ }
262
+ },
263
+ },
264
+ results: {
265
+ async decrypt(body, credential) {
266
+ return decryptCallback(await resolveKey(credential), body)
267
+ },
268
+ verify(jws, opts) {
269
+ return verifyResultFull(jws, { fetchImpl: config.fetch, ...opts })
270
+ },
271
+ async receive(body, opts) {
272
+ // A delivery envelope tagged with a non-terminal event carries a
273
+ // plaintext, unsigned `{ event, eventData }` payload — return it
274
+ // directly, no decryption/verification.
275
+ if(
276
+ body && typeof body === 'object'
277
+ && !(body instanceof Uint8Array)
278
+ && !(body instanceof ArrayBuffer)
279
+ ) {
280
+ const env = body
281
+ if(
282
+ env.event
283
+ && !(TERMINAL_EVENTS as readonly string[]).includes(env.event)
284
+ ) {
285
+ const decoded = parseEventData(env.data)
286
+ return {
287
+ kind: 'event',
288
+ event: env.event,
289
+ eventData: decoded,
290
+ }
291
+ }
292
+ }
293
+
294
+ const { credential, ...verifyOpts } = opts ?? {}
295
+ const key = await resolveKey(credential)
296
+ const jws = await decryptCallback(key, body)
297
+ const result = await verifyResultFull(jws, {
298
+ fetchImpl: config.fetch,
299
+ ...verifyOpts,
300
+ })
301
+ return { kind: 'result', result }
302
+ },
303
+ },
304
+ }
305
+ }
306
+
307
+ /** Non-terminal `data` is a base64-encoded JSON `{ event, eventData }`. */
308
+ function parseEventData(data: string | undefined): Record<string, unknown> {
309
+ if(!data) {
310
+ return {}
311
+ }
312
+
313
+ try {
314
+ const decoded = JSON.parse(
315
+ Buffer.from(data, 'base64').toString('utf8'),
316
+ ) as { eventData?: Record<string, unknown> }
317
+ return decoded.eventData ?? {}
318
+ } catch{
319
+ return {}
320
+ }
321
+ }
322
+
323
+ /** True for a `404` thrown by {@link ReclaimClient.call} — a Boom error whose
324
+ * `output.statusCode` is 404 (see ./errors.ts). */
325
+ function isNotFound(err: unknown): boolean {
326
+ if(typeof err !== 'object' || err === null) {
327
+ return false
328
+ }
329
+
330
+ const boom = err as { output?: { statusCode?: number }, statusCode?: number }
331
+ return boom.output?.statusCode === 404 || boom.statusCode === 404
332
+ }