burnledger 0.8.1 → 0.9.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 (142) hide show
  1. package/README.md +144 -2
  2. package/dist/cjs/anchor.d.ts +5 -3
  3. package/dist/cjs/anchor.d.ts.map +1 -1
  4. package/dist/cjs/anchor.js +10 -4
  5. package/dist/cjs/anchor.js.map +1 -1
  6. package/dist/cjs/client.d.ts +95 -0
  7. package/dist/cjs/client.d.ts.map +1 -1
  8. package/dist/cjs/client.js +175 -54
  9. package/dist/cjs/client.js.map +1 -1
  10. package/dist/cjs/customer-keys.d.ts +73 -1
  11. package/dist/cjs/customer-keys.d.ts.map +1 -1
  12. package/dist/cjs/customer-keys.js +329 -3
  13. package/dist/cjs/customer-keys.js.map +1 -1
  14. package/dist/cjs/enclave-registration.d.ts +165 -0
  15. package/dist/cjs/enclave-registration.d.ts.map +1 -0
  16. package/dist/cjs/enclave-registration.js +287 -0
  17. package/dist/cjs/enclave-registration.js.map +1 -0
  18. package/dist/cjs/enclave-seal.d.ts +43 -0
  19. package/dist/cjs/enclave-seal.d.ts.map +1 -1
  20. package/dist/cjs/enclave-seal.js +62 -1
  21. package/dist/cjs/enclave-seal.js.map +1 -1
  22. package/dist/cjs/errors.d.ts +10 -0
  23. package/dist/cjs/errors.d.ts.map +1 -1
  24. package/dist/cjs/errors.js +11 -1
  25. package/dist/cjs/errors.js.map +1 -1
  26. package/dist/cjs/index.browser.d.ts +7 -3
  27. package/dist/cjs/index.browser.d.ts.map +1 -1
  28. package/dist/cjs/index.browser.js +6 -3
  29. package/dist/cjs/index.browser.js.map +1 -1
  30. package/dist/cjs/index.d.ts +22 -10
  31. package/dist/cjs/index.d.ts.map +1 -1
  32. package/dist/cjs/index.js +75 -6
  33. package/dist/cjs/index.js.map +1 -1
  34. package/dist/cjs/key-group.d.ts +80 -0
  35. package/dist/cjs/key-group.d.ts.map +1 -0
  36. package/dist/cjs/key-group.js +136 -0
  37. package/dist/cjs/key-group.js.map +1 -0
  38. package/dist/cjs/models.d.ts +60 -0
  39. package/dist/cjs/models.d.ts.map +1 -1
  40. package/dist/cjs/models.js +63 -0
  41. package/dist/cjs/models.js.map +1 -1
  42. package/dist/cjs/node-runtime.d.ts +40 -0
  43. package/dist/cjs/node-runtime.d.ts.map +1 -0
  44. package/dist/cjs/node-runtime.js +42 -0
  45. package/dist/cjs/node-runtime.js.map +1 -0
  46. package/dist/cjs/status-document.d.ts +25 -0
  47. package/dist/cjs/status-document.d.ts.map +1 -0
  48. package/dist/cjs/status-document.js +62 -0
  49. package/dist/cjs/status-document.js.map +1 -0
  50. package/dist/cjs/verify.d.ts +108 -3
  51. package/dist/cjs/verify.d.ts.map +1 -1
  52. package/dist/cjs/verify.js +451 -171
  53. package/dist/cjs/verify.js.map +1 -1
  54. package/dist/cjs/web-verifier.d.ts +23 -3
  55. package/dist/cjs/web-verifier.d.ts.map +1 -1
  56. package/dist/cjs/web-verifier.js +31 -5
  57. package/dist/cjs/web-verifier.js.map +1 -1
  58. package/dist/cjs/webhooks.d.ts +9 -2
  59. package/dist/cjs/webhooks.d.ts.map +1 -1
  60. package/dist/cjs/webhooks.js +29 -11
  61. package/dist/cjs/webhooks.js.map +1 -1
  62. package/dist/esm/anchor.d.ts +5 -3
  63. package/dist/esm/anchor.d.ts.map +1 -1
  64. package/dist/esm/anchor.js +10 -4
  65. package/dist/esm/anchor.js.map +1 -1
  66. package/dist/esm/cli.d.ts +30 -17
  67. package/dist/esm/cli.d.ts.map +1 -1
  68. package/dist/esm/cli.js +142 -83
  69. package/dist/esm/cli.js.map +1 -1
  70. package/dist/esm/client.d.ts +95 -0
  71. package/dist/esm/client.d.ts.map +1 -1
  72. package/dist/esm/client.js +175 -21
  73. package/dist/esm/client.js.map +1 -1
  74. package/dist/esm/customer-keys.d.ts +73 -1
  75. package/dist/esm/customer-keys.d.ts.map +1 -1
  76. package/dist/esm/customer-keys.js +323 -3
  77. package/dist/esm/customer-keys.js.map +1 -1
  78. package/dist/esm/enclave-registration.d.ts +165 -0
  79. package/dist/esm/enclave-registration.d.ts.map +1 -0
  80. package/dist/esm/enclave-registration.js +277 -0
  81. package/dist/esm/enclave-registration.js.map +1 -0
  82. package/dist/esm/enclave-seal.d.ts +43 -0
  83. package/dist/esm/enclave-seal.d.ts.map +1 -1
  84. package/dist/esm/enclave-seal.js +61 -1
  85. package/dist/esm/enclave-seal.js.map +1 -1
  86. package/dist/esm/errors.d.ts +10 -0
  87. package/dist/esm/errors.d.ts.map +1 -1
  88. package/dist/esm/errors.js +10 -0
  89. package/dist/esm/errors.js.map +1 -1
  90. package/dist/esm/index.browser.d.ts +7 -3
  91. package/dist/esm/index.browser.d.ts.map +1 -1
  92. package/dist/esm/index.browser.js +6 -3
  93. package/dist/esm/index.browser.js.map +1 -1
  94. package/dist/esm/index.d.ts +22 -10
  95. package/dist/esm/index.d.ts.map +1 -1
  96. package/dist/esm/index.js +30 -8
  97. package/dist/esm/index.js.map +1 -1
  98. package/dist/esm/key-group.d.ts +80 -0
  99. package/dist/esm/key-group.d.ts.map +1 -0
  100. package/dist/esm/key-group.js +130 -0
  101. package/dist/esm/key-group.js.map +1 -0
  102. package/dist/esm/models.d.ts +60 -0
  103. package/dist/esm/models.d.ts.map +1 -1
  104. package/dist/esm/models.js +59 -0
  105. package/dist/esm/models.js.map +1 -1
  106. package/dist/esm/node-runtime.d.ts +40 -0
  107. package/dist/esm/node-runtime.d.ts.map +1 -0
  108. package/dist/esm/node-runtime.js +38 -0
  109. package/dist/esm/node-runtime.js.map +1 -0
  110. package/dist/esm/status-document.d.ts +25 -0
  111. package/dist/esm/status-document.d.ts.map +1 -0
  112. package/dist/esm/status-document.js +59 -0
  113. package/dist/esm/status-document.js.map +1 -0
  114. package/dist/esm/verify.d.ts +108 -3
  115. package/dist/esm/verify.d.ts.map +1 -1
  116. package/dist/esm/verify.js +448 -171
  117. package/dist/esm/verify.js.map +1 -1
  118. package/dist/esm/web-verifier.d.ts +23 -3
  119. package/dist/esm/web-verifier.d.ts.map +1 -1
  120. package/dist/esm/web-verifier.js +27 -5
  121. package/dist/esm/web-verifier.js.map +1 -1
  122. package/dist/esm/webhooks.d.ts +9 -2
  123. package/dist/esm/webhooks.d.ts.map +1 -1
  124. package/dist/esm/webhooks.js +29 -11
  125. package/dist/esm/webhooks.js.map +1 -1
  126. package/package.json +1 -1
  127. package/src/anchor.ts +10 -4
  128. package/src/cli.ts +145 -78
  129. package/src/client.ts +241 -25
  130. package/src/customer-keys.ts +373 -3
  131. package/src/enclave-registration.ts +402 -0
  132. package/src/enclave-seal.ts +108 -1
  133. package/src/errors.ts +11 -0
  134. package/src/index.browser.ts +9 -3
  135. package/src/index.ts +48 -6
  136. package/src/key-group.ts +181 -0
  137. package/src/models.ts +131 -0
  138. package/src/node-runtime.ts +57 -0
  139. package/src/status-document.ts +59 -0
  140. package/src/verify.ts +565 -177
  141. package/src/web-verifier.ts +36 -3
  142. package/src/webhooks.ts +28 -11
package/src/client.ts CHANGED
@@ -48,7 +48,18 @@ import {
48
48
  parseWebhookDelivery,
49
49
  parseWebhookRotateResponse,
50
50
  } from "./models.js";
51
+ import type { KeyEnrollmentResult, SystemRegistrationCertificate } from "./models.js";
51
52
  import { Paginator } from "./pagination.js";
53
+ // Node-only work (enrolment, registration, savePdf) is reached through here, not
54
+ // by naming the Node modules — so the browser entry that shares this class never
55
+ // pulls node:crypto or node:fs into a bundle. See node-runtime.ts.
56
+ import { loadNodeRuntime } from "./node-runtime.js";
57
+ // Types only: erased at build, so the browser entry that shares this module
58
+ // never loads node:crypto. The code behind them is imported lazily, on use.
59
+ import type { CustomerKeyGroup } from "./customer-keys.js";
60
+ import type { EnclavePinOptions, RegisteredSystem } from "./enclave-registration.js";
61
+ import type { EnclaveIdentity } from "./enclave-seal.js";
62
+ import type { KeyGroupSigner } from "./key-group.js";
52
63
 
53
64
  type Raw = Record<string, unknown>;
54
65
 
@@ -102,6 +113,13 @@ export class BurnLedger {
102
113
  connectionConfig?: Record<string, unknown>;
103
114
  dsn?: string;
104
115
  uri?: string;
116
+ /**
117
+ * The connection config already sealed to the enclave — by
118
+ * sealConnectionConfig, or by createRegisteredSystem, which does it for you.
119
+ * The API relays these bytes and cannot open them. Instead of
120
+ * connectionConfig/dsn/uri, never beside them.
121
+ */
122
+ sealedConnectionConfig?: Uint8Array;
105
123
  subjectQuery: string;
106
124
  hashScope?: string;
107
125
  /** The system holds data you treat as PHI. Makes the default proof mode
@@ -112,15 +130,19 @@ export class BurnLedger {
112
130
  maxBytes?: number;
113
131
  queryTimeout?: string;
114
132
  }): Promise<System> {
115
- const config = resolveConnectionConfig(
116
- opts.connectionConfig,
117
- opts.dsn,
118
- opts.uri,
119
- );
133
+ const sealed = opts.sealedConnectionConfig;
134
+ if (
135
+ sealed !== undefined &&
136
+ (opts.connectionConfig !== undefined || opts.dsn !== undefined || opts.uri !== undefined)
137
+ ) {
138
+ throw new Error("Cannot provide both sealedConnectionConfig and connectionConfig/dsn/uri.");
139
+ }
120
140
  const body = {
121
141
  name: opts.name,
122
142
  connector_type: opts.connectorType,
123
- connection_config: config,
143
+ ...(sealed === undefined
144
+ ? { connection_config: resolveConnectionConfig(opts.connectionConfig, opts.dsn, opts.uri) }
145
+ : { sealed_connection_config: bytesToBase64(sealed) }),
124
146
  subject_query: opts.subjectQuery,
125
147
  hash_scope: opts.hashScope ?? "existence",
126
148
  phi_in_scope: opts.phiInScope ?? false,
@@ -137,7 +159,7 @@ export class BurnLedger {
137
159
  async getSystem(systemId: string): Promise<System> {
138
160
  const data = await this.transport.request(
139
161
  "GET",
140
- `/v1/systems/${systemId}`,
162
+ `/v1/systems/${encodeId(systemId)}`,
141
163
  );
142
164
  return parseSystem(data as Raw);
143
165
  }
@@ -149,13 +171,13 @@ export class BurnLedger {
149
171
  }
150
172
 
151
173
  async deregisterSystem(systemId: string): Promise<void> {
152
- await this.transport.request("DELETE", `/v1/systems/${systemId}`);
174
+ await this.transport.request("DELETE", `/v1/systems/${encodeId(systemId)}`);
153
175
  }
154
176
 
155
177
  async healthCheck(systemId: string): Promise<System> {
156
178
  const data = await this.transport.request(
157
179
  "POST",
158
- `/v1/systems/${systemId}/health-check`,
180
+ `/v1/systems/${encodeId(systemId)}/health-check`,
159
181
  );
160
182
  return parseSystem(data as Raw);
161
183
  }
@@ -188,7 +210,7 @@ export class BurnLedger {
188
210
  async getAttestation(attestationId: string): Promise<Attestation> {
189
211
  const data = await this.transport.request(
190
212
  "GET",
191
- `/v1/attestations/${attestationId}`,
213
+ `/v1/attestations/${encodeId(attestationId)}`,
192
214
  );
193
215
  return parseAttestation(data as Raw);
194
216
  }
@@ -214,7 +236,7 @@ export class BurnLedger {
214
236
  const body = { subject_identifier: subjectIdentifier };
215
237
  const data = await this.transport.request(
216
238
  "POST",
217
- `/v1/attestations/${attestationId}/verify`,
239
+ `/v1/attestations/${encodeId(attestationId)}/verify`,
218
240
  { json: body },
219
241
  );
220
242
  const result = parseVerifyResult(data as Raw);
@@ -228,7 +250,7 @@ export class BurnLedger {
228
250
  fetch: async () => {
229
251
  const d = await this.transport.request(
230
252
  "POST",
231
- `/v1/attestations/${attestationId}/verify`,
253
+ `/v1/attestations/${encodeId(attestationId)}/verify`,
232
254
  { json: body },
233
255
  );
234
256
  return parseVerifyResult(d as Raw);
@@ -263,7 +285,7 @@ export class BurnLedger {
263
285
  async getCertificate(certificateId: string): Promise<CertificateResponse> {
264
286
  const data = await this.transport.request(
265
287
  "GET",
266
- `/v1/certificates/${certificateId}`,
288
+ `/v1/certificates/${encodeId(certificateId)}`,
267
289
  );
268
290
  return parseCertificateResponse(data as Raw);
269
291
  }
@@ -282,20 +304,20 @@ export class BurnLedger {
282
304
  async downloadPdf(certificateId: string): Promise<Uint8Array> {
283
305
  return this.transport.requestBytes(
284
306
  "GET",
285
- `/v1/certificates/${certificateId}/pdf`,
307
+ `/v1/certificates/${encodeId(certificateId)}/pdf`,
286
308
  );
287
309
  }
288
310
 
289
311
  async savePdf(certificateId: string, path: string): Promise<void> {
290
312
  const pdf = await this.downloadPdf(certificateId);
291
- const { writeFile } = await import("node:fs/promises");
313
+ const { writeFile } = await loadNodeRuntime();
292
314
  await writeFile(path, pdf);
293
315
  }
294
316
 
295
317
  async getRevocationStatus(certificateId: string): Promise<RevocationStatus> {
296
318
  const data = await this.transport.request(
297
319
  "GET",
298
- `/v1/certificates/${certificateId}/revocation-status`,
320
+ `/v1/certificates/${encodeId(certificateId)}/revocation-status`,
299
321
  );
300
322
  return parseRevocationStatus(data as Raw);
301
323
  }
@@ -306,7 +328,7 @@ export class BurnLedger {
306
328
  ): Promise<CertificateResponse> {
307
329
  const data = await this.transport.request(
308
330
  "POST",
309
- `/v1/certificates/${certificateId}/revoke`,
331
+ `/v1/certificates/${encodeId(certificateId)}/revoke`,
310
332
  { json: { reason: opts.reason } },
311
333
  );
312
334
  return parseCertificateResponse(data as Raw);
@@ -371,13 +393,13 @@ export class BurnLedger {
371
393
  }
372
394
 
373
395
  async deleteWebhook(webhookId: string): Promise<void> {
374
- await this.transport.request("DELETE", `/v1/webhooks/${webhookId}`);
396
+ await this.transport.request("DELETE", `/v1/webhooks/${encodeId(webhookId)}`);
375
397
  }
376
398
 
377
399
  async rotateWebhookSecret(webhookId: string): Promise<WebhookRotateResponse> {
378
400
  const data = await this.transport.request(
379
401
  "POST",
380
- `/v1/webhooks/${webhookId}/rotate-secret`,
402
+ `/v1/webhooks/${encodeId(webhookId)}/rotate-secret`,
381
403
  );
382
404
  return parseWebhookRotateResponse(data as Raw);
383
405
  }
@@ -385,7 +407,7 @@ export class BurnLedger {
385
407
  async commitWebhookRotation(webhookId: string): Promise<void> {
386
408
  await this.transport.request(
387
409
  "POST",
388
- `/v1/webhooks/${webhookId}/commit-rotation`,
410
+ `/v1/webhooks/${encodeId(webhookId)}/commit-rotation`,
389
411
  );
390
412
  }
391
413
 
@@ -401,14 +423,14 @@ export class BurnLedger {
401
423
  async retryDelivery(deliveryId: string): Promise<void> {
402
424
  await this.transport.request(
403
425
  "POST",
404
- `/v1/webhooks/deliveries/${deliveryId}/retry`,
426
+ `/v1/webhooks/deliveries/${encodeId(deliveryId)}/retry`,
405
427
  );
406
428
  }
407
429
 
408
430
  async resolveDelivery(deliveryId: string): Promise<void> {
409
431
  await this.transport.request(
410
432
  "DELETE",
411
- `/v1/webhooks/deliveries/${deliveryId}`,
433
+ `/v1/webhooks/deliveries/${encodeId(deliveryId)}`,
412
434
  );
413
435
  }
414
436
 
@@ -435,7 +457,7 @@ export class BurnLedger {
435
457
  }
436
458
 
437
459
  async revokeApiKey(keyId: string): Promise<void> {
438
- await this.transport.request("DELETE", `/v1/api-keys/${keyId}`);
460
+ await this.transport.request("DELETE", `/v1/api-keys/${encodeId(keyId)}`);
439
461
  }
440
462
 
441
463
  // --- Profile ---
@@ -458,12 +480,177 @@ export class BurnLedger {
458
480
  await this.transport.request("DELETE", "/v1/me");
459
481
  }
460
482
 
483
+ // --- Customer key groups and enclave registration (ADR-025; Node only) ---
484
+ //
485
+ // These load node:crypto on first use, lazily, because this class is also the
486
+ // browser entry's. ADR-025 §2 makes a client the customer runs locally the
487
+ // only place enrolment can happen, so a browser has no business here anyway.
488
+ // `teamId` is the team every signed payload binds; it is also sent as
489
+ // asserted_team_id, so naming the wrong team is a 403 that says so rather than
490
+ // an enclave 401 that cannot.
491
+
492
+ /**
493
+ * Verify the enclave: `GET /v1/enclave/attestation` over a 32-byte challenge
494
+ * drawn here (or `pin.nonce`), checked against the PCR0 you pinned out of band.
495
+ * Returns the keys the document binds. Throws EnclaveAttestationError on
496
+ * anything short of a document from the pinned image answering this challenge.
497
+ * Pass the result as `enclave` to the calls below: configs are sealed to it,
498
+ * and every document they return must verify under its signing key.
499
+ */
500
+ async attestEnclaveIdentity(pin: EnclavePinOptions): Promise<EnclaveIdentity> {
501
+ const { registration: flow } = await loadNodeRuntime();
502
+ const { nonce, params } = flow.attestationParams(pin.nonce);
503
+ const data = await this.transport.request("GET", "/v1/enclave/attestation", {
504
+ params,
505
+ authenticated: false,
506
+ });
507
+ return flow.identityFromResponse(data, { ...pin, nonce });
508
+ }
509
+
510
+ /**
511
+ * Enrol a key group for your team (ENROLL_KEY). Every member signs; the private
512
+ * keys stay with the signers. `notAfter` is the latest the authorization may
513
+ * end, signed by every member so a relayed copy of this request cannot renew
514
+ * it; it defaults to the enclave's 90-day maximum. Throws VerificationError
515
+ * unless both documents that come back verify under `enclave`'s signing key,
516
+ * name exactly this group — ADR-025 §2's one comparison — and your team, end
517
+ * exactly at `notAfter` and do not start after `now` (this machine's clock by default).
518
+ */
519
+ async enrollKeyGroup(opts: {
520
+ teamId: string;
521
+ group: CustomerKeyGroup;
522
+ signers: readonly KeyGroupSigner[];
523
+ enclave: EnclaveIdentity;
524
+ notAfter?: Date;
525
+ /** The time to judge the answer's not_before against. Defaults to now. */
526
+ now?: Date;
527
+ }): Promise<KeyEnrollmentResult> {
528
+ const { registration: flow } = await loadNodeRuntime();
529
+ const { body, keyId, notAfter } = await flow.enrollBody(opts);
530
+ const data = await this.transport.request("POST", "/v1/enclave/keys", { json: body });
531
+ return flow.checkEnrollment(data, {
532
+ enclave: opts.enclave,
533
+ keyId,
534
+ prevKeyId: undefined,
535
+ teamId: opts.teamId,
536
+ notAfter,
537
+ now: opts.now,
538
+ });
539
+ }
540
+
541
+ /**
542
+ * Replace an enrolled group with another (ROTATE_KEY). The OUTGOING group
543
+ * signs at its threshold and EVERY incoming member signs, over the same
544
+ * request and `notAfter`. Systems registered under the old group keep working
545
+ * through the link this issues; nothing is re-registered. The answer is checked
546
+ * as `enrollKeyGroup`'s is, and must also link to the outgoing group.
547
+ */
548
+ async rotateKeyGroup(opts: {
549
+ teamId: string;
550
+ previousGroup: CustomerKeyGroup;
551
+ previousSigners: readonly KeyGroupSigner[];
552
+ nextGroup: CustomerKeyGroup;
553
+ nextSigners: readonly KeyGroupSigner[];
554
+ enclave: EnclaveIdentity;
555
+ notAfter?: Date;
556
+ /** The time to judge the answer's not_before against. Defaults to now. */
557
+ now?: Date;
558
+ }): Promise<KeyEnrollmentResult> {
559
+ const { registration: flow } = await loadNodeRuntime();
560
+ const { body, prevKeyId, nextKeyId, notAfter } = await flow.rotateBody(opts);
561
+ const data = await this.transport.request("POST", "/v1/enclave/keys/rotate", { json: body });
562
+ return flow.checkEnrollment(data, {
563
+ enclave: opts.enclave,
564
+ keyId: nextKeyId,
565
+ prevKeyId,
566
+ teamId: opts.teamId,
567
+ notAfter,
568
+ now: opts.now,
569
+ });
570
+ }
571
+
572
+ /**
573
+ * Register an existing system under your key group (REGISTER_SYSTEM), signed at
574
+ * the group's threshold. `config` must be the exact bytes submitted when the
575
+ * system was created, and `queryTemplate` the system's subject query exactly.
576
+ * The registration that comes back must verify under `enclave`'s signing key.
577
+ */
578
+ async registerSystemWithKey(opts: {
579
+ teamId: string;
580
+ group: CustomerKeyGroup;
581
+ signers: readonly KeyGroupSigner[];
582
+ enclave: EnclaveIdentity;
583
+ systemId: string;
584
+ config: Uint8Array | string;
585
+ queryTemplate: string;
586
+ connectorType: string;
587
+ }): Promise<SystemRegistrationCertificate> {
588
+ const { registration: flow } = await loadNodeRuntime();
589
+ const { body, keyId, configDigest } = await flow.registrationBody(opts);
590
+ const data = await this.transport.request("POST", "/v1/enclave/registrations", { json: body });
591
+ return flow.checkRegistration(data, {
592
+ enclave: opts.enclave,
593
+ keyId,
594
+ systemId: opts.systemId,
595
+ configDigest,
596
+ connectorType: opts.connectorType,
597
+ });
598
+ }
599
+
600
+ /**
601
+ * ADR-025's whole customer path for a new system: seal the config to `enclave`
602
+ * (from {@link attestEnclaveIdentity}), create the system with only the sealed
603
+ * bytes, and register it under your key group.
604
+ */
605
+ async createRegisteredSystem(opts: {
606
+ teamId: string;
607
+ group: CustomerKeyGroup;
608
+ signers: readonly KeyGroupSigner[];
609
+ enclave: EnclaveIdentity;
610
+ name: string;
611
+ connectorType: string;
612
+ connectionConfig?: Record<string, unknown>;
613
+ dsn?: string;
614
+ uri?: string;
615
+ subjectQuery: string;
616
+ hashScope?: string;
617
+ phiInScope?: boolean;
618
+ maxRecords?: number;
619
+ maxBytes?: number;
620
+ queryTimeout?: string;
621
+ }): Promise<RegisteredSystem> {
622
+ const { registration: flow, seal } = await loadNodeRuntime();
623
+ const config = flow.configBytes(resolveConnectionConfig(opts.connectionConfig, opts.dsn, opts.uri));
624
+ const system = await this.registerSystem({
625
+ name: opts.name,
626
+ connectorType: opts.connectorType,
627
+ subjectQuery: opts.subjectQuery,
628
+ hashScope: opts.hashScope,
629
+ phiInScope: opts.phiInScope,
630
+ maxRecords: opts.maxRecords,
631
+ maxBytes: opts.maxBytes,
632
+ queryTimeout: opts.queryTimeout,
633
+ sealedConnectionConfig: await seal.sealToKey(opts.enclave.configSealKey, config),
634
+ });
635
+ const registration = await this.registerSystemWithKey({
636
+ teamId: opts.teamId,
637
+ group: opts.group,
638
+ signers: opts.signers,
639
+ enclave: opts.enclave,
640
+ systemId: system.id,
641
+ config,
642
+ queryTemplate: opts.subjectQuery,
643
+ connectorType: opts.connectorType,
644
+ });
645
+ return { system, registration };
646
+ }
647
+
461
648
  // --- System Health ---
462
649
 
463
650
  async getSystemHealth(systemId: string): Promise<SystemHealth> {
464
651
  const data = await this.transport.request(
465
652
  "GET",
466
- `/v1/systems/${systemId}/health`,
653
+ `/v1/systems/${encodeId(systemId)}/health`,
467
654
  );
468
655
  return parseSystemHealth(data as Raw);
469
656
  }
@@ -532,6 +719,28 @@ export class BurnLedger {
532
719
  // Connection config resolution
533
720
  // ---------------------------------------------------------------------------
534
721
 
722
+ /** Percent-encode an id before it goes into a URL path, and refuse `.`/`..`.
723
+ *
724
+ * A raw id lets untrusted input escape its segment: `deregisterSystem("x/../../
725
+ * api-keys/k1")` would otherwise resolve to `DELETE /v1/api-keys/k1`.
726
+ * encodeURIComponent escapes the `/`, but it leaves `.` and `..` untouched, and
727
+ * a segment that IS `.` or `..` is still traversal — so those are rejected
728
+ * outright. An empty id is rejected too: it collapses two path segments into one. */
729
+ function encodeId(id: string): string {
730
+ if (id === "" || id === "." || id === "..") {
731
+ throw new Error(`invalid id path segment: ${JSON.stringify(id)}`);
732
+ }
733
+ return encodeURIComponent(id);
734
+ }
735
+
736
+ /** Standard base64, as Go decodes a []byte field. No Buffer: this module is
737
+ * also the browser entry's. */
738
+ function bytesToBase64(bytes: Uint8Array): string {
739
+ let binary = "";
740
+ for (const b of bytes) binary += String.fromCharCode(b);
741
+ return btoa(binary);
742
+ }
743
+
535
744
  function resolveConnectionConfig(
536
745
  connectionConfig: Record<string, unknown> | undefined,
537
746
  dsn: string | undefined,
@@ -577,7 +786,14 @@ async function poll<T>(opts: {
577
786
  result = await opts.fetch();
578
787
  } catch (err) {
579
788
  if (err instanceof RateLimitError && err.retryAfter !== undefined) {
580
- await sleep(err.retryAfter * 1000);
789
+ const remaining = deadline - Date.now();
790
+ if (remaining <= 0) {
791
+ throw new TimeoutError(opts.operation, elapsed);
792
+ }
793
+ // Bound the wait to the caller's remaining budget: a server asking for a
794
+ // day, or an Infinity Retry-After, must not block past the timeout the
795
+ // caller set (Math.min(Infinity, remaining) === remaining).
796
+ await sleep(Math.min(err.retryAfter * 1000, remaining));
581
797
  continue;
582
798
  }
583
799
  throw err;