@openresidency/sdk 0.1.0 → 0.2.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/dist/index.js CHANGED
@@ -1,12 +1,3 @@
1
- // SPDX-License-Identifier: Apache-2.0
2
- /**
3
- * OpenResidency Interoperability SDK.
4
- *
5
- * A small, dependency-free typed client for the OpenResidency API. Uses the global
6
- * fetch (Node 18+ or any browser). Every method maps one-to-one to an endpoint in
7
- * docs/openapi.yaml, so a sector service (Health, Tax, ...) or a partner system can
8
- * integrate without hand-writing HTTP calls.
9
- */
10
1
  export class OpenResidencyError extends Error {
11
2
  status;
12
3
  body;
@@ -16,6 +7,9 @@ export class OpenResidencyError extends Error {
16
7
  this.body = body;
17
8
  }
18
9
  }
10
+ // ---------------------------------------------------------------------------------------
11
+ // Client
12
+ // ---------------------------------------------------------------------------------------
19
13
  export class OpenResidencyClient {
20
14
  baseUrl;
21
15
  operatorKey;
@@ -29,110 +23,369 @@ export class OpenResidencyClient {
29
23
  this.operatorToken = opts.operatorToken;
30
24
  this.doFetch = opts.fetch ?? fetch;
31
25
  }
26
+ // ---- the whole contract ----
27
+ /**
28
+ * Call any operation in docs/openapi.yaml. Path parameters, query, body and the
29
+ * response are typed from the generated contract:
30
+ *
31
+ * ```ts
32
+ * const rel = await client.request('get', '/residency/{residentId}/relationship', {
33
+ * path: { residentId },
34
+ * });
35
+ * ```
36
+ */
37
+ request(method, path, ...args) {
38
+ const opts = (args[0] ?? {});
39
+ return this.send(method, path, opts);
40
+ }
41
+ // ---- health ----
42
+ live() {
43
+ return this.request('get', '/health/live', { auth: 'none' });
44
+ }
45
+ ready() {
46
+ return this.request('get', '/health/ready', { auth: 'none' });
47
+ }
32
48
  // ---- identity ----
33
49
  /** Operator action: needs the `registrar` role. */
34
50
  identityChallenge(countryCode, identifiers) {
35
- return this.post('/identity/challenge', { countryCode, identifiers }, true);
51
+ return this.send('post', '/identity/challenge', {
52
+ body: { countryCode, identifiers },
53
+ auth: 'operator',
54
+ });
36
55
  }
37
56
  /** Operator action: needs the `registrar` role. */
38
57
  verifyIdentity(req) {
39
- return this.post('/identity/verify', req, true);
58
+ return this.send('post', '/identity/verify', { body: req, auth: 'operator' });
40
59
  }
41
60
  // ---- residency ----
42
61
  countries() {
43
- return this.get('/residency/countries');
62
+ return this.send('get', '/residency/countries', { auth: 'none' });
44
63
  }
45
64
  /** Operator action: needs the `registrar` role. */
46
65
  issueResidency(req) {
47
- return this.post('/residency/issue', req, true);
66
+ return this.send('post', '/residency/issue', { body: req, auth: 'operator' });
48
67
  }
49
68
  residencyStatus(residentId) {
50
- return this.get(`/residency/${encodeURIComponent(residentId)}`);
69
+ return this.send('get', '/residency/{residentId}', { path: { residentId } });
51
70
  }
52
71
  verifyCredential(credential, offline = false) {
53
- return this.post('/residency/verify', { credential, offline });
72
+ return this.send('post', '/residency/verify', {
73
+ body: { credential, offline },
74
+ });
54
75
  }
55
76
  /** Operator action: needs the `revoker` role. */
56
77
  revokeResidency(residentId) {
57
- return this.post(`/residency/revoke/${encodeURIComponent(residentId)}`, {}, true);
78
+ return this.send('post', '/residency/revoke/{residentId}', {
79
+ path: { residentId },
80
+ body: {},
81
+ auth: 'operator',
82
+ });
83
+ }
84
+ /** Operator action: needs the `admin` role. Revokes first, then destroys personal data. */
85
+ eraseResidency(residentId, body) {
86
+ return this.request('post', '/residency/{residentId}/erase', {
87
+ path: { residentId },
88
+ body,
89
+ auth: 'operator',
90
+ });
91
+ }
92
+ /** Operator action. Erases records whose retention period has ended. */
93
+ retentionSweep(body) {
94
+ return this.request('post', '/residency/retention/sweep', { body, auth: 'operator' });
95
+ }
96
+ /** Operator action. Expires provisional registrations that were never completed. */
97
+ provisionalSweep(body) {
98
+ return this.request('post', '/residency/provisional/sweep', { body, auth: 'operator' });
99
+ }
100
+ /** Operator action. Re-verifies a resident against the foundational source. */
101
+ reconcile(residentId, body) {
102
+ return this.request('post', '/residency/{residentId}/reconcile', {
103
+ path: { residentId },
104
+ body,
105
+ auth: 'operator',
106
+ });
107
+ }
108
+ // ---- relationship and credential lifecycle (ORCS §6, §10) ----
109
+ /** The relationship's ORCS state and how it got there. */
110
+ relationship(residentId) {
111
+ return this.request('get', '/residency/{residentId}/relationship', { path: { residentId } });
112
+ }
113
+ /** Operator action. Move the relationship to a new ORCS state. */
114
+ transitionRelationship(residentId, body) {
115
+ return this.request('post', '/residency/{residentId}/relationship/transition', {
116
+ path: { residentId },
117
+ body,
118
+ auth: 'operator',
119
+ });
120
+ }
121
+ /** The credential's ORCS status. */
122
+ credential(residentId) {
123
+ return this.request('get', '/residency/{residentId}/credential', { path: { residentId } });
124
+ }
125
+ /** Operator action. Move the credential to a new ORCS status. */
126
+ transitionCredential(residentId, body) {
127
+ return this.request('post', '/residency/{residentId}/credential/transition', {
128
+ path: { residentId },
129
+ body,
130
+ auth: 'operator',
131
+ });
132
+ }
133
+ /** Why an application was refused, and how to appeal. */
134
+ refusal(reference) {
135
+ return this.request('get', '/residency/refusals/{reference}', { path: { reference } });
136
+ }
137
+ /** Operator action. Record the outcome of reviewing a refusal. */
138
+ reviewRefusal(reference, body) {
139
+ return this.request('post', '/residency/refusals/{reference}/review', {
140
+ path: { reference },
141
+ body,
142
+ auth: 'operator',
143
+ });
58
144
  }
59
- // ---- consent ----
145
+ // ---- assurance (ORCS §7) ----
146
+ assuranceProfiles() {
147
+ return this.request('get', '/assurance/profiles', { auth: 'none' });
148
+ }
149
+ assuranceMappings() {
150
+ return this.request('get', '/assurance/mappings', { auth: 'none' });
151
+ }
152
+ /** Resolve a source-specific assurance value to the ORCS assurance profile. */
153
+ resolveAssurance(value) {
154
+ return this.request('get', '/assurance/resolve/{value}', { path: { value }, auth: 'none' });
155
+ }
156
+ residentAssurance(residentId) {
157
+ return this.request('get', '/residency/{residentId}/assurance', { path: { residentId } });
158
+ }
159
+ // ---- consent (ORCS §9) ----
60
160
  // The consent routes are operator-guarded server-side (support role), so they carry
61
161
  // credentials like the admin ones. They previously did not, and 401'd.
62
162
  listConsents(residentId) {
63
- return this.get(`/consent/resident/${encodeURIComponent(residentId)}`, true);
163
+ return this.send('get', '/consent/resident/{residentId}', {
164
+ path: { residentId },
165
+ auth: 'operator',
166
+ });
64
167
  }
65
168
  grantConsent(req) {
66
- return this.post('/consent/grant', req, true);
169
+ return this.send('post', '/consent/grant', { body: req, auth: 'operator' });
67
170
  }
68
171
  revokeConsent(id) {
69
- return this.post(`/consent/${encodeURIComponent(id)}/revoke`, {}, true);
172
+ return this.send('post', '/consent/{id}/revoke', { path: { id }, body: {}, auth: 'operator' });
173
+ }
174
+ legalBases() {
175
+ return this.request('get', '/consent/legal-bases');
176
+ }
177
+ legalBasis(id) {
178
+ return this.request('get', '/consent/legal-bases/{id}', { path: { id } });
179
+ }
180
+ /** Operator action. Withdraw a legal basis; consents resting on it stop being valid. */
181
+ deactivateLegalBasis(id, body) {
182
+ return this.request('post', '/consent/legal-bases/{id}/deactivate', { path: { id }, body, auth: 'operator' });
183
+ }
184
+ // ---- operator identity ----
185
+ /** Local sign-in (`operatorAuth.mode: local`). The token goes in `ClientOptions.operatorToken`. */
186
+ operatorLogin(body) {
187
+ return this.request('post', '/operator/login', { body, auth: 'none' });
188
+ }
189
+ /** The calling operator's identity and roles. */
190
+ me() {
191
+ return this.request('get', '/operator/me', { auth: 'operator' });
70
192
  }
71
- // ---- admin (operator-authenticated) ----
193
+ listOperators() {
194
+ return this.request('get', '/operator/operators', { auth: 'operator' });
195
+ }
196
+ createOperator(body) {
197
+ return this.request('post', '/operator/operators', { body, auth: 'operator' });
198
+ }
199
+ /** Disable (or with `disabled: false`, re-enable) an operator account. Needs the `admin` role. */
200
+ disableOperator(operatorId, disabled = true) {
201
+ return this.request('post', '/operator/operators/{id}/disable', {
202
+ path: { id: operatorId },
203
+ body: { disabled },
204
+ auth: 'operator',
205
+ });
206
+ }
207
+ listKeys() {
208
+ return this.request('get', '/operator/keys', { auth: 'operator' });
209
+ }
210
+ /** Mint a per-operator API key. The secret is returned once. */
211
+ createKey(body) {
212
+ return this.request('post', '/operator/keys', { body, auth: 'operator' });
213
+ }
214
+ /** Mint a replacement key; the old one keeps working for the overlap window. */
215
+ rotateKey(body) {
216
+ return this.request('post', '/operator/keys/rotate', { body, auth: 'operator' });
217
+ }
218
+ revokeKey(body) {
219
+ return this.request('post', '/operator/keys/revoke', { body, auth: 'operator' });
220
+ }
221
+ // ---- audit and admin (operator-authenticated) ----
72
222
  listResidents(params = {}) {
73
- const q = new URLSearchParams();
74
- if (params.countryCode)
75
- q.set('countryCode', params.countryCode);
76
- if (params.limit != null)
77
- q.set('limit', String(params.limit));
78
- if (params.offset != null)
79
- q.set('offset', String(params.offset));
80
- return this.get(`/admin/residents?${q.toString()}`, true);
223
+ return this.send('get', '/admin/residents', { query: params, auth: 'operator' });
81
224
  }
82
225
  auditLog(params = {}) {
83
- const q = new URLSearchParams();
84
- if (params.limit != null)
85
- q.set('limit', String(params.limit));
86
- if (params.offset != null)
87
- q.set('offset', String(params.offset));
88
- if (params.target)
89
- q.set('target', params.target);
90
- return this.get(`/audit?${q.toString()}`, true);
226
+ return this.send('get', '/audit', { query: params, auth: 'operator' });
91
227
  }
92
228
  verifyAuditChain() {
93
- return this.get('/audit/verify', true);
229
+ return this.send('get', '/audit/verify', { auth: 'operator' });
230
+ }
231
+ /** Counts by country. */
232
+ stats() {
233
+ return this.request('get', '/admin/stats', { auth: 'operator' });
234
+ }
235
+ /** Aggregate, non-PII statistics (the open-data surface), as JSON. */
236
+ statistics(query) {
237
+ return this.request('get', '/admin/statistics', { query, auth: 'operator' });
238
+ }
239
+ /** The same report as RFC 4180 CSV. */
240
+ statisticsCsv(query) {
241
+ return this.request('get', '/admin/statistics.csv', { query, auth: 'operator', accept: 'text/csv' });
242
+ }
243
+ // ---- offline ----
244
+ /** Render a credential as an SVG QR for paper or low-connectivity carriage. */
245
+ qr(body) {
246
+ return this.request('post', '/offline/qr', { body });
247
+ }
248
+ /** The USSD aggregator webhook. `secret` is the shared USSD_GATEWAY_SECRET. */
249
+ ussd(body, secret) {
250
+ return this.request('post', '/offline/ussd', { body, auth: { headers: { 'x-ussd-secret': secret } } });
251
+ }
252
+ // ---- OpenID4VCI: issuing into a wallet ----
253
+ credentialIssuerMetadata() {
254
+ return this.request('get', '/.well-known/openid-credential-issuer', { auth: 'none' });
255
+ }
256
+ oauthAuthorizationServerMetadata() {
257
+ return this.request('get', '/.well-known/oauth-authorization-server', { auth: 'none' });
258
+ }
259
+ /** Operator action. Create a credential offer for a resident's wallet to redeem. */
260
+ createCredentialOffer(body) {
261
+ return this.request('post', '/openid4vci/offer', { body, auth: 'operator' });
262
+ }
263
+ /** The wallet side: exchange the pre-authorized code for an access token. */
264
+ walletToken(body) {
265
+ return this.request('post', '/openid4vci/token', { body, auth: 'none' });
266
+ }
267
+ walletNonce() {
268
+ return this.request('post', '/openid4vci/nonce', { auth: 'none' });
269
+ }
270
+ /** The wallet side: obtain the credential with the access token from `walletToken`. */
271
+ walletCredential(accessToken, body) {
272
+ return this.request('post', '/openid4vci/credential', { body, auth: { bearer: accessToken } });
273
+ }
274
+ // ---- OpenID4VP: asking a wallet to present ----
275
+ /** Create a presentation request for a wallet to answer. */
276
+ createPresentationRequest(body) {
277
+ return this.request('post', '/openid4vp/request', { body });
278
+ }
279
+ /** The request object a wallet fetches (the `request_uri`). */
280
+ presentationRequest(id) {
281
+ return this.request('get', '/openid4vp/request/{id}', { path: { id }, auth: 'none' });
282
+ }
283
+ /** The wallet side: submit the presentation. */
284
+ submitPresentation(id, body) {
285
+ return this.request('post', '/openid4vp/response/{id}', { path: { id }, body, auth: 'none' });
286
+ }
287
+ /** What the wallet presented, once it has. */
288
+ presentationResult(id) {
289
+ return this.request('get', '/openid4vp/result/{id}', { path: { id } });
290
+ }
291
+ // ---- W3C VC-API ----
292
+ /** Operator action. Issue a credential through the VC-API issuer interface. */
293
+ vcIssue(body) {
294
+ return this.request('post', '/credentials/issue', { body, auth: 'operator' });
295
+ }
296
+ /** Verify a Verifiable Credential (JWT or Data Integrity) through the VC-API verifier interface. */
297
+ vcVerify(body) {
298
+ return this.request('post', '/credentials/verify', { body });
299
+ }
300
+ /** Verify a Verifiable Presentation through the VC-API verifier interface. */
301
+ vpVerify(body) {
302
+ return this.request('post', '/presentations/verify', { body });
94
303
  }
95
304
  // ---- discovery ----
96
- oidcDiscovery() {
97
- return this.get('/oidc/.well-known/openid-configuration');
305
+ /** This deployment's DID document (`did:web`). */
306
+ didDocument() {
307
+ return this.request('get', '/.well-known/did.json', { auth: 'none' });
98
308
  }
99
- // ---- internals ----
100
- async get(path, admin = false) {
101
- return this.request('GET', path, undefined, admin);
309
+ /** The DID document for one country's issuer key. */
310
+ didDocumentFor(countryCode) {
311
+ return this.request('get', '/.well-known/did/{countryCode}.json', { path: { countryCode }, auth: 'none' });
102
312
  }
103
- async post(path, body, admin = false) {
104
- return this.request('POST', path, body, admin);
313
+ /** A Bitstring Status List credential, for offline revocation checks. */
314
+ statusList(file) {
315
+ return this.request('get', '/.well-known/status/{file}', { path: { file }, auth: 'none' });
105
316
  }
106
- async request(method, path, body, admin = false) {
107
- const headers = { accept: 'application/json' };
108
- if (body !== undefined)
109
- headers['content-type'] = 'application/json';
110
- if (admin) {
111
- // Preference order matches how much the deployment can tell about the caller:
112
- // an operator key or SSO token names a person; the shared key names nobody.
113
- if (this.operatorKey) {
114
- headers['x-operator-key'] = this.operatorKey;
115
- }
116
- else if (this.operatorToken) {
117
- headers['authorization'] = `Bearer ${this.operatorToken}`;
118
- }
119
- else if (this.adminKey) {
120
- headers['x-admin-key'] = this.adminKey;
121
- }
122
- else {
123
- throw new Error('This endpoint requires operator authentication: set operatorKey (preferred), ' +
124
- 'operatorToken, or the legacy adminKey in ClientOptions');
125
- }
317
+ /** OpenID Connect discovery for the SSO provider mounted under /oidc. */
318
+ oidcDiscovery() {
319
+ return this.request('get', '/oidc/.well-known/openid-configuration', { auth: 'none' });
320
+ }
321
+ // ---- internals ----
322
+ async send(method, template, opts) {
323
+ // Parameter names are identifiers; the bounded class keeps the scan linear.
324
+ const path = template.replace(/\{([A-Za-z0-9_]+)\}/g, (_, name) => {
325
+ const value = opts.path?.[name];
326
+ if (value === undefined)
327
+ throw new Error(`Missing path parameter "${name}" for ${template}`);
328
+ return encodeURIComponent(String(value));
329
+ });
330
+ const q = new URLSearchParams();
331
+ for (const [k, v] of Object.entries(opts.query ?? {})) {
332
+ if (v === undefined || v === null)
333
+ continue;
334
+ if (Array.isArray(v))
335
+ v.forEach((item) => q.append(k, String(item)));
336
+ else
337
+ q.set(k, String(v));
126
338
  }
127
- const res = await this.doFetch(`${this.baseUrl}${path}`, {
128
- method,
339
+ const qs = q.toString();
340
+ const headers = { accept: opts.accept ?? 'application/json' };
341
+ if (opts.body !== undefined)
342
+ headers['content-type'] = 'application/json';
343
+ this.applyAuth(headers, opts.auth ?? 'auto');
344
+ Object.assign(headers, opts.headers);
345
+ const res = await this.doFetch(`${this.baseUrl}${path}${qs ? `?${qs}` : ''}`, {
346
+ method: method.toUpperCase(),
129
347
  headers,
130
- body: body !== undefined ? JSON.stringify(body) : undefined,
348
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
131
349
  });
132
350
  const text = await res.text();
133
- const parsed = text ? JSON.parse(text) : undefined;
351
+ const isJson = (res.headers.get('content-type') ?? '').includes('json');
352
+ let parsed = text;
353
+ if (isJson || headers.accept === 'application/json') {
354
+ try {
355
+ parsed = text ? JSON.parse(text) : undefined;
356
+ }
357
+ catch {
358
+ parsed = text;
359
+ }
360
+ }
134
361
  if (!res.ok)
135
362
  throw new OpenResidencyError(res.status, parsed);
136
363
  return parsed;
137
364
  }
365
+ applyAuth(headers, auth) {
366
+ if (auth === 'none')
367
+ return;
368
+ if (typeof auth === 'object') {
369
+ if ('bearer' in auth)
370
+ headers['authorization'] = `Bearer ${auth.bearer}`;
371
+ else
372
+ Object.assign(headers, auth.headers);
373
+ return;
374
+ }
375
+ // Preference order matches how much the deployment can tell about the caller:
376
+ // an operator key or SSO token names a person; the shared key names nobody.
377
+ if (this.operatorKey) {
378
+ headers['x-operator-key'] = this.operatorKey;
379
+ }
380
+ else if (this.operatorToken) {
381
+ headers['authorization'] = `Bearer ${this.operatorToken}`;
382
+ }
383
+ else if (this.adminKey) {
384
+ headers['x-admin-key'] = this.adminKey;
385
+ }
386
+ else if (auth === 'operator') {
387
+ throw new Error('This endpoint requires operator authentication: set operatorKey (preferred), ' +
388
+ 'operatorToken, or the legacy adminKey in ClientOptions');
389
+ }
390
+ }
138
391
  }