@witnium-tech/witniumchain 0.2.0 → 0.5.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.mjs CHANGED
@@ -1,23 +1,114 @@
1
1
  // src/errors.ts
2
- var WitniumAccountsApiError = class extends Error {
2
+ var WitniumchainApiError = class extends Error {
3
3
  status;
4
4
  errorLabel;
5
5
  body;
6
6
  constructor(args) {
7
7
  super(args.message);
8
- this.name = "WitniumAccountsApiError";
8
+ this.name = "WitniumchainApiError";
9
9
  this.status = args.status;
10
10
  this.errorLabel = args.errorLabel;
11
11
  this.body = args.body ?? null;
12
12
  }
13
13
  };
14
14
 
15
+ // src/pkce.ts
16
+ var STORAGE_PREFIX = "witniumchain.pkce.";
17
+ function defaultVerifierStorage() {
18
+ const storage = globalThis.sessionStorage;
19
+ if (!storage) {
20
+ throw new Error(
21
+ "WitniumchainClient: defaultVerifierStorage requires globalThis.sessionStorage. In a non-browser context, pass `verifierStorage` to the OAuth helpers."
22
+ );
23
+ }
24
+ return {
25
+ set(stateKey, verifier) {
26
+ storage.setItem(STORAGE_PREFIX + stateKey, verifier);
27
+ },
28
+ get(stateKey) {
29
+ return storage.getItem(STORAGE_PREFIX + stateKey);
30
+ },
31
+ remove(stateKey) {
32
+ storage.removeItem(STORAGE_PREFIX + stateKey);
33
+ }
34
+ };
35
+ }
36
+ function generateCodeVerifier() {
37
+ const bytes = new Uint8Array(64);
38
+ cryptoRef().getRandomValues(bytes);
39
+ return base64UrlEncode(bytes);
40
+ }
41
+ async function deriveCodeChallenge(verifier) {
42
+ const data = new TextEncoder().encode(verifier);
43
+ const digest = await cryptoRef().subtle.digest("SHA-256", data);
44
+ return base64UrlEncode(new Uint8Array(digest));
45
+ }
46
+ function generateState() {
47
+ const bytes = new Uint8Array(32);
48
+ cryptoRef().getRandomValues(bytes);
49
+ return base64UrlEncode(bytes);
50
+ }
51
+ function base64UrlEncode(bytes) {
52
+ let binary = "";
53
+ for (const b of bytes) binary += String.fromCharCode(b);
54
+ const b64 = typeof btoa === "function" ? btoa(binary) : Buffer.from(binary, "binary").toString("base64");
55
+ return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
56
+ }
57
+ function cryptoRef() {
58
+ const c = globalThis.crypto;
59
+ if (!c || !c.subtle || !c.getRandomValues) {
60
+ throw new Error(
61
+ "WitniumchainClient: globalThis.crypto with subtle + getRandomValues is required for PKCE. Modern browsers and Node 18+ provide this natively."
62
+ );
63
+ }
64
+ return c;
65
+ }
66
+
15
67
  // src/client.ts
16
- var WitniumAccountsClient = class {
68
+ var WitniumchainClient = class {
17
69
  baseUrl;
70
+ chainBaseUrl;
18
71
  cfg;
19
72
  timeout;
20
73
  fetchImpl;
74
+ // Mutable OAuth state. Constructor seeds these from the config; the OAuth
75
+ // flow helpers (begin/complete/refresh/signOut) read and rewrite them as
76
+ // the user authenticates and tokens rotate. `applyAuth` reads `accessToken`
77
+ // off this field — never directly off `cfg` — so a token rotated mid-flight
78
+ // (during a 401-retry refresh) is picked up by the very next call.
79
+ accessToken;
80
+ // Browser SPAs in production receive the refresh token as an HttpOnly cookie
81
+ // (see src/oauth/refresh-cookie.ts on the server), so this field stays
82
+ // `undefined` and the cookie rides via `credentials: 'include'` on every
83
+ // /token call. Non-browser callers (Node SSR, native apps without a cookie
84
+ // jar) get the refresh token in the response body and the SDK stashes it
85
+ // here as a fallback. Either path drives `refreshAccessToken` identically.
86
+ refreshToken;
87
+ // Sentinel: the most recent token response set an HttpOnly refresh cookie.
88
+ // The SDK can't directly observe an HttpOnly cookie, but the response body
89
+ // tells us indirectly — server strips `refresh_token` when it sets the
90
+ // cookie, so an absent body field on a successful /token response means
91
+ // the cookie path is in use. Used to gate the 401-retry: with a cookie,
92
+ // refresh might work even when the in-memory refresh token is undefined.
93
+ hasRefreshCookie = false;
94
+ oauthClientId;
95
+ // The redirect URI the most recent `beginOAuthLogin` issued, alongside the
96
+ // PKCE verifier. `completeOAuthLogin` reads it back so the token-exchange
97
+ // request matches the original /auth request (RFC 6749 §4.1.3 requires
98
+ // redirect_uri at /token to equal the one at /auth). Keyed by state.
99
+ pendingLogins = /* @__PURE__ */ new Map();
100
+ verifierStorage;
101
+ // Cache of the parsed OIDC discovery document keyed by issuer URL. Saves a
102
+ // round trip on every OAuth call after the first. oidc-provider's discovery
103
+ // doc is static for the life of an issuer; cache is per-client-instance, so
104
+ // it dies with the SDK consumer's lifecycle.
105
+ discoveryCache;
106
+ // Single-flight gate for refresh. When a 401 fans out to N concurrent retries
107
+ // (or the consumer calls refreshAccessToken directly while another refresh
108
+ // is mid-flight), all callers await the same in-flight promise — refresh
109
+ // tokens rotate on use (D10), so a second concurrent refresh would race the
110
+ // first and one of them would 401.
111
+ refreshInFlight;
21
112
  /** Subscriptions / billing helpers. See {@link Subscriptions}. */
22
113
  subscriptions;
23
114
  /** Delegated-key namespace including the one-call {@link DelegatedKeys.provision} flow. */
@@ -26,23 +117,30 @@ var WitniumAccountsClient = class {
26
117
  keys;
27
118
  /** OAuth session management. Accessed as `client.oauth.sessions.*`. */
28
119
  oauth;
120
+ /** MFA self-management. Accessed as `client.mfa.totp.*` and `client.mfa.recoveryCodes.*`. */
121
+ mfa;
29
122
  constructor(config) {
30
123
  if (!config.baseUrl) {
31
- throw new Error("WitniumAccountsClient: baseUrl is required");
124
+ throw new Error("WitniumchainClient: baseUrl is required");
32
125
  }
33
126
  this.cfg = config;
34
127
  this.baseUrl = config.baseUrl.replace(/\/$/, "");
128
+ this.chainBaseUrl = config.chainBaseUrl?.replace(/\/$/, "");
35
129
  this.timeout = config.timeout ?? 3e4;
36
130
  this.fetchImpl = config.fetch ?? globalThis.fetch;
37
131
  if (!this.fetchImpl) {
38
132
  throw new Error(
39
- "WitniumAccountsClient: no fetch implementation available. Pass `config.fetch`."
133
+ "WitniumchainClient: no fetch implementation available. Pass `config.fetch`."
40
134
  );
41
135
  }
136
+ this.accessToken = config.accessToken;
137
+ this.oauthClientId = config.oauthClientId;
138
+ this.verifierStorage = config.verifierStorage;
42
139
  this.subscriptions = new Subscriptions(this);
43
140
  this.delegatedKeys = new DelegatedKeys(this);
44
141
  this.keys = new SigningKeys(this);
45
142
  this.oauth = new OauthNamespace(this);
143
+ this.mfa = new MfaNamespace(this);
46
144
  }
47
145
  /**
48
146
  * Convenience alias for {@link getAccount} — returns the authenticated
@@ -210,8 +308,8 @@ var WitniumAccountsClient = class {
210
308
  // ────────────────────────────────────────────────────────────────────────
211
309
  // Witnesses (/v1/contracts/{addr}/witnesses/*)
212
310
  // ────────────────────────────────────────────────────────────────────────
213
- proposeWitness(contractAddress, body, idempotencyKey) {
214
- const key = idempotencyKey ?? randomUUID();
311
+ async proposeWitness(contractAddress, body, idempotencyKey) {
312
+ const key = idempotencyKey ?? await deriveBodyKey("v1:propose", contractAddress, body);
215
313
  return this.req(
216
314
  "POST",
217
315
  `/v1/contracts/${encodeURIComponent(contractAddress)}/witnesses/propose`,
@@ -232,8 +330,8 @@ var WitniumAccountsClient = class {
232
330
  { auth: "SignedRequest" }
233
331
  );
234
332
  }
235
- revokeWitness(contractAddress, witnessId, body, idempotencyKey) {
236
- const key = idempotencyKey ?? randomUUID();
333
+ async revokeWitness(contractAddress, witnessId, body, idempotencyKey) {
334
+ const key = idempotencyKey ?? await deriveBodyKey("v1:revoke", contractAddress, { witnessId, body });
237
335
  return this.req(
238
336
  "POST",
239
337
  `/v1/contracts/${encodeURIComponent(contractAddress)}/witnesses/${encodeURIComponent(witnessId)}/revoke`,
@@ -255,12 +353,15 @@ var WitniumAccountsClient = class {
255
353
  // chain-api with the admin token. Auth is OAuth Bearer; the URL
256
354
  // contract must match the user's bound contract.
257
355
  //
258
- // The propose/revoke methods auto-generate an Idempotency-Key when the
259
- // caller doesn't supply one server side that header is part of the
260
- // billing identity and is required.
356
+ // The propose/revoke methods default Idempotency-Key to a stable hash
357
+ // of the request body so an application-level retry with the same
358
+ // arguments dedupes against the original reservation instead of
359
+ // burning a second credit. Pass an explicit `idempotencyKey` to
360
+ // override (e.g. if you genuinely want two witnesses for the same
361
+ // body).
261
362
  // ────────────────────────────────────────────────────────────────────────
262
- proposeWitnessV5(contractAddress, body, idempotencyKey) {
263
- const key = idempotencyKey ?? randomUUID();
363
+ async proposeWitnessV5(contractAddress, body, idempotencyKey) {
364
+ const key = idempotencyKey ?? await deriveBodyKey("v5:propose", contractAddress, body);
264
365
  return this.req(
265
366
  "POST",
266
367
  `/v5/contracts/${encodeURIComponent(contractAddress)}/witnesses/propose`,
@@ -281,8 +382,8 @@ var WitniumAccountsClient = class {
281
382
  { auth: "BearerJWT" }
282
383
  );
283
384
  }
284
- revokeWitnessV5(contractAddress, witnessId, body, idempotencyKey) {
285
- const key = idempotencyKey ?? randomUUID();
385
+ async revokeWitnessV5(contractAddress, witnessId, body, idempotencyKey) {
386
+ const key = idempotencyKey ?? await deriveBodyKey("v5:revoke", contractAddress, { witnessId, body });
286
387
  return this.req(
287
388
  "POST",
288
389
  `/v5/contracts/${encodeURIComponent(contractAddress)}/witnesses/${encodeURIComponent(witnessId)}/revoke`,
@@ -299,6 +400,37 @@ var WitniumAccountsClient = class {
299
400
  return this.req("GET", "/v1/account/ledger", { auth: "SessionCookie" });
300
401
  }
301
402
  // ────────────────────────────────────────────────────────────────────────
403
+ // MFA (/v1/account/mfa/*)
404
+ //
405
+ // SessionCookie auth (self-management). Returns the same shapes the
406
+ // dashboard renders directly; the SDK is also a viable consumer for any
407
+ // Node-side tooling that needs to programmatically enrol a service account.
408
+ //
409
+ // The MFA challenge step that runs inside the OAuth interaction (Thread E)
410
+ // is NOT here — it's a server-rendered HTML form, not a JSON surface.
411
+ // ────────────────────────────────────────────────────────────────────────
412
+ enrollTotp() {
413
+ return this.req("POST", "/v1/account/mfa/totp/enroll", {
414
+ auth: "SessionCookie"
415
+ });
416
+ }
417
+ confirmTotp(body) {
418
+ return this.req("POST", "/v1/account/mfa/totp/confirm", {
419
+ auth: "SessionCookie",
420
+ body
421
+ });
422
+ }
423
+ disableTotp() {
424
+ return this.req("DELETE", "/v1/account/mfa/totp", {
425
+ auth: "SessionCookie"
426
+ });
427
+ }
428
+ regenerateRecoveryCodes() {
429
+ return this.req("POST", "/v1/account/mfa/recovery-codes/regenerate", {
430
+ auth: "SessionCookie"
431
+ });
432
+ }
433
+ // ────────────────────────────────────────────────────────────────────────
302
434
  // OAuth sessions (/v1/oauth/sessions*)
303
435
  // ────────────────────────────────────────────────────────────────────────
304
436
  listOauthSessions() {
@@ -325,12 +457,408 @@ var WitniumAccountsClient = class {
325
457
  healthReady() {
326
458
  return this.req("GET", "/health/ready", { auth: "Public" });
327
459
  }
460
+ // ════════════════════════════════════════════════════════════════════════
461
+ // Chain-api reads (called against chainBaseUrl, e.g. api.witniumchain.com)
462
+ //
463
+ // Routing: every method here passes `service: 'chain'` to `req()`. Calls
464
+ // throw at call time if `chainBaseUrl` wasn't configured.
465
+ //
466
+ // Auth: BearerJWT. The accounts-issued OAuth access token already carries
467
+ // `aud=https://api.witniumchain.com`, so the same `accessToken` works
468
+ // unchanged against both services.
469
+ //
470
+ // What's NOT here: chain-api v5 WRITES (propose/sign/finalize/revoke). Those
471
+ // are proxied by accounts (proposeWitnessV5, etc.) so credits get reserved
472
+ // and idempotency is enforced. See docs/PLAN-PHASE-SDK-UNIFIED.md for the
473
+ // routing rules; calling chain-api writes directly would burn credits
474
+ // without billing them.
475
+ // ════════════════════════════════════════════════════════════════════════
476
+ getContractInfo(contractAddress) {
477
+ return this.req(
478
+ "GET",
479
+ `/v5/contracts/${encodeURIComponent(contractAddress)}/info`,
480
+ { auth: "BearerJWT", service: "chain" }
481
+ );
482
+ }
483
+ getContractVerification(contractAddress) {
484
+ return this.req(
485
+ "GET",
486
+ `/v5/contracts/${encodeURIComponent(contractAddress)}/verify`,
487
+ { auth: "BearerJWT", service: "chain" }
488
+ );
489
+ }
490
+ listWitnessesV5(contractAddress, params) {
491
+ return this.req(
492
+ "GET",
493
+ `/v5/contracts/${encodeURIComponent(contractAddress)}/witnesses`,
494
+ { auth: "BearerJWT", service: "chain", query: params }
495
+ );
496
+ }
497
+ getWitnessV5(contractAddress, witnessId) {
498
+ return this.req(
499
+ "GET",
500
+ `/v5/contracts/${encodeURIComponent(contractAddress)}/witnesses/${encodeURIComponent(witnessId)}`,
501
+ { auth: "BearerJWT", service: "chain" }
502
+ );
503
+ }
504
+ getContractTransaction(contractAddress, txHash) {
505
+ return this.req(
506
+ "GET",
507
+ `/v5/contracts/${encodeURIComponent(contractAddress)}/transactions/${encodeURIComponent(txHash)}`,
508
+ { auth: "BearerJWT", service: "chain" }
509
+ );
510
+ }
511
+ getTransaction(txHash) {
512
+ return this.req(
513
+ "GET",
514
+ `/v5/transactions/${encodeURIComponent(txHash)}`,
515
+ { auth: "BearerJWT", service: "chain" }
516
+ );
517
+ }
518
+ getWalletBalance(address) {
519
+ return this.req(
520
+ "GET",
521
+ `/v5/wallets/${encodeURIComponent(address)}/balance`,
522
+ { auth: "BearerJWT", service: "chain" }
523
+ );
524
+ }
525
+ getDashboardContract() {
526
+ return this.req("GET", "/v5/dashboard/contract", {
527
+ auth: "BearerJWT",
528
+ service: "chain"
529
+ });
530
+ }
531
+ getDashboardWitnesses(params) {
532
+ return this.req("GET", "/v5/dashboard/witnesses", {
533
+ auth: "BearerJWT",
534
+ service: "chain",
535
+ query: params
536
+ });
537
+ }
538
+ // ════════════════════════════════════════════════════════════════════════
539
+ // OAuth 2.1 + PKCE flow helpers (Phase AUTH Thread A)
540
+ //
541
+ // Designed for browser SPAs without a backend ("Sign in with Witnium" for a
542
+ // Lovable customer). The flow:
543
+ //
544
+ // 1. App calls `beginOAuthLogin({ redirectUri })`, gets a URL, redirects.
545
+ // 2. User authenticates at auth.witniumchain.com, server redirects back
546
+ // to the app's redirectUri with `?code=…&state=…`.
547
+ // 3. App calls `completeOAuthLogin(window.location.href)`, gets back an
548
+ // access token. The SDK stashes it in memory; subsequent `BearerJWT`
549
+ // calls use it transparently.
550
+ // 4. When a `BearerJWT` call returns 401 (token expired), `req()` calls
551
+ // `refreshAccessToken` once and retries. Caller never sees the 401.
552
+ //
553
+ // Access tokens live in memory only — never localStorage. They die on tab
554
+ // close; the refresh token (held in memory today, planned to migrate to an
555
+ // HttpOnly cookie set by /token server-side) survives long enough for a
556
+ // silent refresh on the next /completeOAuthLogin or 401-retry. Refresh
557
+ // tokens rotate on every use, so a single-flight gate avoids racing two
558
+ // concurrent refreshes against the same token.
559
+ //
560
+ // Endpoint discovery: the SDK fetches /.well-known/openid-configuration
561
+ // from `baseUrl` once and reuses the parsed result. oidc-provider's paths
562
+ // (/auth, /token, /jwks, etc.) are not hard-coded — if accounts ever moves
563
+ // them, only the discovery doc has to be right.
564
+ // ════════════════════════════════════════════════════════════════════════
565
+ /**
566
+ * Build the authorization-server URL for the start of an OAuth login flow.
567
+ *
568
+ * Generates a fresh PKCE verifier + challenge, stashes the verifier in the
569
+ * configured {@link PkceVerifierStorage} under the `state` key, and returns
570
+ * the URL the caller should redirect the user to. Side-effects:
571
+ *
572
+ * - sessionStorage gets a PKCE entry under `witniumchain.pkce.<state>`.
573
+ * - The client instance remembers the `redirectUri` for this `state`
574
+ * so {@link completeOAuthLogin} can rebuild the matching token request
575
+ * without the caller threading the URI through twice.
576
+ *
577
+ * The caller is responsible for the redirect itself
578
+ * (`window.location.assign(result.authorizationUrl)`); the SDK doesn't
579
+ * touch `window` directly so SSR + non-browser callers are not broken.
580
+ */
581
+ async beginOAuthLogin(args) {
582
+ if (!this.oauthClientId) {
583
+ throw new WitniumchainApiError({
584
+ status: 0,
585
+ message: "WitniumchainClient: beginOAuthLogin requires `oauthClientId` to be set on the constructor."
586
+ });
587
+ }
588
+ const discovery = await this.fetchDiscovery();
589
+ const state = args.state ?? generateState();
590
+ const verifier = generateCodeVerifier();
591
+ const challenge = await deriveCodeChallenge(verifier);
592
+ const scope = (args.scope ?? ["openid", "profile", "email"]).join(" ");
593
+ const url = new URL(discovery.authorization_endpoint);
594
+ url.searchParams.set("response_type", "code");
595
+ url.searchParams.set("client_id", this.oauthClientId);
596
+ url.searchParams.set("redirect_uri", args.redirectUri);
597
+ url.searchParams.set("scope", scope);
598
+ url.searchParams.set("state", state);
599
+ url.searchParams.set("code_challenge", challenge);
600
+ url.searchParams.set("code_challenge_method", "S256");
601
+ if (args.prompt) url.searchParams.set("prompt", args.prompt);
602
+ this.verifierStorageOrDefault().set(state, verifier);
603
+ this.pendingLogins.set(state, { redirectUri: args.redirectUri });
604
+ return { authorizationUrl: url.toString(), state };
605
+ }
606
+ /**
607
+ * Exchange an authorization-code callback for an access token.
608
+ *
609
+ * Reads `code` and `state` from the callback URL, validates the state
610
+ * against a stored verifier, exchanges the code at the token endpoint,
611
+ * and stores the access token (and refresh token) in the client's
612
+ * in-memory state. Returns the access token + its expiry for callers
613
+ * who want to display the session or schedule a proactive refresh.
614
+ *
615
+ * Throws (without consuming the verifier) when the callback URL is
616
+ * missing `code` or `state`, or when the state has no matching verifier
617
+ * — the latter happens when the user opens an old/forged callback URL,
618
+ * or when sessionStorage was cleared between authorize and callback.
619
+ *
620
+ * The caller is responsible for stripping `code` and `state` from the
621
+ * browser URL afterwards (`window.history.replaceState`) so a refresh
622
+ * doesn't re-trigger the exchange against an already-consumed code.
623
+ */
624
+ async completeOAuthLogin(callbackUrl) {
625
+ if (!this.oauthClientId) {
626
+ throw new WitniumchainApiError({
627
+ status: 0,
628
+ message: "WitniumchainClient: completeOAuthLogin requires `oauthClientId` to be set on the constructor."
629
+ });
630
+ }
631
+ const url = callbackUrl instanceof URL ? callbackUrl : new URL(callbackUrl);
632
+ const code = url.searchParams.get("code");
633
+ const state = url.searchParams.get("state");
634
+ const error = url.searchParams.get("error");
635
+ if (error) {
636
+ throw new WitniumchainApiError({
637
+ status: 0,
638
+ message: `OAuth authorize returned error: ${error}${url.searchParams.get("error_description") ? ` (${url.searchParams.get("error_description")})` : ""}`,
639
+ errorLabel: error
640
+ });
641
+ }
642
+ if (!code || !state) {
643
+ throw new WitniumchainApiError({
644
+ status: 0,
645
+ message: "WitniumchainClient: callbackUrl missing required `code` and/or `state` parameters."
646
+ });
647
+ }
648
+ const storage = this.verifierStorageOrDefault();
649
+ const verifier = storage.get(state);
650
+ if (!verifier) {
651
+ throw new WitniumchainApiError({
652
+ status: 0,
653
+ message: "WitniumchainClient: no PKCE verifier stored for this `state`. The login flow either was not started in this tab, was already completed, or the sessionStorage entry was cleared."
654
+ });
655
+ }
656
+ const pending = this.pendingLogins.get(state);
657
+ if (!pending) {
658
+ storage.remove(state);
659
+ throw new WitniumchainApiError({
660
+ status: 0,
661
+ message: "WitniumchainClient: PKCE verifier exists but the redirectUri for this `state` was not found in client memory (the client instance was replaced between beginOAuthLogin and completeOAuthLogin)."
662
+ });
663
+ }
664
+ const discovery = await this.fetchDiscovery();
665
+ const form = new URLSearchParams();
666
+ form.set("grant_type", "authorization_code");
667
+ form.set("code", code);
668
+ form.set("redirect_uri", pending.redirectUri);
669
+ form.set("client_id", this.oauthClientId);
670
+ form.set("code_verifier", verifier);
671
+ const tokens = await this.postTokenEndpoint(discovery.token_endpoint, form);
672
+ storage.remove(state);
673
+ this.pendingLogins.delete(state);
674
+ return this.persistTokenResponse(tokens);
675
+ }
676
+ /**
677
+ * Refresh the access token. Sends `grant_type=refresh_token` to the token
678
+ * endpoint with the in-memory refresh token, and updates `accessToken`
679
+ * (and the rotated refresh token) on success.
680
+ *
681
+ * Concurrent callers — including the 401-retry interceptor inside `req()`
682
+ * fanning out N parallel calls — share a single in-flight refresh promise:
683
+ * refresh tokens rotate on use, so issuing two concurrent refreshes would
684
+ * race and one would 401 with `invalid_grant`.
685
+ *
686
+ * Throws `WitniumchainApiError` if the refresh token is missing (the SDK
687
+ * was constructed without one and `completeOAuthLogin` was never called)
688
+ * or if the AS rejects the refresh (token revoked / expired). On rejection
689
+ * the in-memory tokens are cleared so subsequent BearerJWT calls fail fast
690
+ * instead of retrying with a now-invalid token.
691
+ */
692
+ async refreshAccessToken() {
693
+ if (this.refreshInFlight) return this.refreshInFlight;
694
+ this.refreshInFlight = (async () => {
695
+ try {
696
+ return await this.refreshAccessTokenInternal();
697
+ } finally {
698
+ this.refreshInFlight = void 0;
699
+ }
700
+ })();
701
+ return this.refreshInFlight;
702
+ }
703
+ /**
704
+ * End the OAuth session.
705
+ *
706
+ * Clears the in-memory access + refresh tokens. Best-effort revocation of
707
+ * the server-side session would require a server endpoint that accepts a
708
+ * Bearer-token-authenticated DELETE (today's `/v1/oauth/sessions` requires
709
+ * the first-party `wac_session` cookie, see oauth-sessions.controller.ts).
710
+ * That endpoint will arrive with Phase AUTH Thread E; until then,
711
+ * `signOut` clears local state only and the access token's natural TTL
712
+ * (currently 60 min) bounds residual risk if the refresh token is also
713
+ * dropped — which it is, here.
714
+ */
715
+ signOut() {
716
+ this.accessToken = void 0;
717
+ this.refreshToken = void 0;
718
+ this.hasRefreshCookie = false;
719
+ this.pendingLogins.clear();
720
+ }
721
+ // ────────────────────────────────────────────────────────────────────────
722
+ // Internal: OAuth flow helpers
328
723
  // ────────────────────────────────────────────────────────────────────────
329
- // Internal: fetch wrapper that maps non-2xx → WitniumAccountsApiError
724
+ verifierStorageOrDefault() {
725
+ if (this.verifierStorage) return this.verifierStorage;
726
+ return defaultVerifierStorage();
727
+ }
728
+ async fetchDiscovery() {
729
+ if (this.discoveryCache) return this.discoveryCache;
730
+ this.discoveryCache = (async () => {
731
+ const url = `${this.baseUrl}/.well-known/openid-configuration`;
732
+ let res;
733
+ try {
734
+ res = await this.fetchImpl(url, {
735
+ method: "GET",
736
+ headers: { accept: "application/json" }
737
+ });
738
+ } catch (err) {
739
+ this.discoveryCache = void 0;
740
+ throw new WitniumchainApiError({
741
+ status: 0,
742
+ message: err instanceof Error ? `OIDC discovery fetch failed: ${err.message}` : "OIDC discovery fetch failed"
743
+ });
744
+ }
745
+ if (!res.ok) {
746
+ this.discoveryCache = void 0;
747
+ throw new WitniumchainApiError({
748
+ status: res.status,
749
+ message: `OIDC discovery fetch failed: HTTP ${res.status}`
750
+ });
751
+ }
752
+ const parsed = await res.json();
753
+ if (!parsed.authorization_endpoint || !parsed.token_endpoint) {
754
+ this.discoveryCache = void 0;
755
+ throw new WitniumchainApiError({
756
+ status: 0,
757
+ message: "OIDC discovery doc is missing `authorization_endpoint` or `token_endpoint`."
758
+ });
759
+ }
760
+ return {
761
+ authorization_endpoint: parsed.authorization_endpoint,
762
+ token_endpoint: parsed.token_endpoint,
763
+ issuer: parsed.issuer ?? this.baseUrl
764
+ };
765
+ })();
766
+ return this.discoveryCache;
767
+ }
768
+ async postTokenEndpoint(endpoint, form) {
769
+ const controller = new AbortController();
770
+ const timer = setTimeout(() => controller.abort(), this.timeout);
771
+ let res;
772
+ try {
773
+ res = await this.fetchImpl(endpoint, {
774
+ method: "POST",
775
+ headers: {
776
+ accept: "application/json",
777
+ "content-type": "application/x-www-form-urlencoded"
778
+ },
779
+ body: form.toString(),
780
+ signal: controller.signal,
781
+ // Sends the HttpOnly refresh-token cookie when the server starts
782
+ // setting it (planned server-side change); a no-op until then.
783
+ credentials: "include"
784
+ });
785
+ } catch (err) {
786
+ throw new WitniumchainApiError({
787
+ status: 0,
788
+ message: err instanceof Error ? `Token endpoint fetch failed: ${err.message}` : "Token endpoint fetch failed"
789
+ });
790
+ } finally {
791
+ clearTimeout(timer);
792
+ }
793
+ const text = await res.text();
794
+ let parsed = null;
795
+ if (text.length > 0) {
796
+ try {
797
+ parsed = JSON.parse(text);
798
+ } catch {
799
+ }
800
+ }
801
+ if (!res.ok) {
802
+ throw this.parseApiError(res.status, parsed, text);
803
+ }
804
+ return parsed;
805
+ }
806
+ persistTokenResponse(tokens) {
807
+ if (!tokens.access_token) {
808
+ throw new WitniumchainApiError({
809
+ status: 0,
810
+ message: "Token endpoint response missing `access_token`."
811
+ });
812
+ }
813
+ this.accessToken = tokens.access_token;
814
+ if (tokens.refresh_token) {
815
+ this.refreshToken = tokens.refresh_token;
816
+ } else {
817
+ this.refreshToken = void 0;
818
+ this.hasRefreshCookie = true;
819
+ }
820
+ const ttlSeconds = typeof tokens.expires_in === "number" ? tokens.expires_in : 3600;
821
+ const expiresAt = Math.floor(Date.now() / 1e3) + ttlSeconds;
822
+ return { accessToken: tokens.access_token, expiresAt };
823
+ }
824
+ async refreshAccessTokenInternal() {
825
+ if (!this.oauthClientId) {
826
+ throw new WitniumchainApiError({
827
+ status: 0,
828
+ message: "WitniumchainClient: refreshAccessToken requires `oauthClientId` to be set on the constructor."
829
+ });
830
+ }
831
+ if (!this.refreshToken && !this.hasRefreshCookie) {
832
+ throw new WitniumchainApiError({
833
+ status: 0,
834
+ message: "WitniumchainClient: no refresh credential available. Call `completeOAuthLogin` first \u2014 the server delivers the refresh token either in the response body (Node) or as an HttpOnly cookie (browser)."
835
+ });
836
+ }
837
+ const discovery = await this.fetchDiscovery();
838
+ const form = new URLSearchParams();
839
+ form.set("grant_type", "refresh_token");
840
+ form.set("client_id", this.oauthClientId);
841
+ if (this.refreshToken) {
842
+ form.set("refresh_token", this.refreshToken);
843
+ }
844
+ try {
845
+ const tokens = await this.postTokenEndpoint(discovery.token_endpoint, form);
846
+ return this.persistTokenResponse(tokens);
847
+ } catch (err) {
848
+ this.accessToken = void 0;
849
+ this.refreshToken = void 0;
850
+ this.hasRefreshCookie = false;
851
+ throw err;
852
+ }
853
+ }
854
+ // ────────────────────────────────────────────────────────────────────────
855
+ // Internal: fetch wrapper that maps non-2xx → WitniumchainApiError
330
856
  // and applies the configured credential to the request.
331
857
  // ────────────────────────────────────────────────────────────────────────
332
858
  async req(method, path, opts) {
333
- const url = this.buildUrl(path, opts.query);
859
+ const pathWithQuery = this.buildPathWithQuery(path, opts.query);
860
+ const base = this.resolveBaseUrl(opts.service);
861
+ const url = `${base}${pathWithQuery}`;
334
862
  const headers = {
335
863
  accept: "application/json",
336
864
  ...opts.headers ?? {}
@@ -339,7 +867,13 @@ var WitniumAccountsClient = class {
339
867
  headers["content-type"] = "application/json";
340
868
  }
341
869
  const bodyString = opts.body !== void 0 ? JSON.stringify(opts.body) : void 0;
342
- await this.applyAuth(headers, opts.auth, method, path, bodyString ?? "");
870
+ await this.applyAuth(
871
+ headers,
872
+ opts.auth,
873
+ method,
874
+ pathWithQuery,
875
+ bodyString ?? ""
876
+ );
343
877
  const controller = new AbortController();
344
878
  const timer = setTimeout(() => controller.abort(), this.timeout);
345
879
  let res;
@@ -354,15 +888,18 @@ var WitniumAccountsClient = class {
354
888
  credentials: "include"
355
889
  });
356
890
  } catch (err) {
357
- throw new WitniumAccountsApiError({
891
+ throw new WitniumchainApiError({
358
892
  status: 0,
359
- message: err instanceof Error ? `Network error contacting ${this.baseUrl}: ${err.message}` : `Network error contacting ${this.baseUrl}`
893
+ message: err instanceof Error ? `Network error contacting ${base}: ${err.message}` : `Network error contacting ${base}`
360
894
  });
361
895
  } finally {
362
896
  clearTimeout(timer);
363
897
  }
364
898
  if (opts.expectNoContent) {
365
899
  if (!res.ok) {
900
+ if (await this.shouldRefreshAndRetry(res, opts)) {
901
+ return this.req(method, path, { ...opts, _isRetry: true });
902
+ }
366
903
  throw await this.toApiError(res);
367
904
  }
368
905
  return void 0;
@@ -376,18 +913,93 @@ var WitniumAccountsClient = class {
376
913
  }
377
914
  }
378
915
  if (!res.ok) {
916
+ if (await this.shouldRefreshAndRetryParsed(res.status, parsed, opts)) {
917
+ return this.req(method, path, { ...opts, _isRetry: true });
918
+ }
379
919
  throw this.parseApiError(res.status, parsed, text);
380
920
  }
381
921
  return parsed;
382
922
  }
383
- buildUrl(path, query) {
384
- if (!query) return `${this.baseUrl}${path}`;
923
+ /**
924
+ * Decide whether a non-2xx response on a `BearerJWT` route should trigger
925
+ * a refresh + single retry. Returns false (no retry) when:
926
+ *
927
+ * - this call IS the retry — never recurse,
928
+ * - the auth mode isn't BearerJWT — refresh only helps Bearer tokens,
929
+ * - the status isn't 401 — refresh doesn't unstick 403/404/500/etc,
930
+ * - we don't have a refresh token in memory — nothing to refresh with,
931
+ * - the response body doesn't look like an expired-token signal.
932
+ *
933
+ * On a positive answer, the method ALSO performs the refresh in-line:
934
+ * the caller just gets back `true` and replays the original request,
935
+ * which picks up the freshly-rotated `accessToken` via `applyAuth`.
936
+ * Refresh failures are swallowed here (the caller falls through to the
937
+ * regular error path); `refreshAccessTokenInternal` already cleared the
938
+ * in-memory tokens so the retry won't have anything to send.
939
+ */
940
+ async shouldRefreshAndRetry(res, opts) {
941
+ if (opts._isRetry) return false;
942
+ if (opts.auth !== "BearerJWT") return false;
943
+ if (res.status !== 401) return false;
944
+ if (!this.refreshToken && !this.hasRefreshCookie) return false;
945
+ try {
946
+ await this.refreshAccessToken();
947
+ return true;
948
+ } catch {
949
+ return false;
950
+ }
951
+ }
952
+ /**
953
+ * Same decision as `shouldRefreshAndRetry` but for the JSON-parsed path,
954
+ * where we have the body. Adds one extra gate: only retry when the parsed
955
+ * body looks like a "token expired" signal (`error: 'token_expired'` per
956
+ * the AUTH plan, or `error: 'invalid_token'` per RFC 6750 §3.1). A 401
957
+ * with any other `error` label is a real authn/authz failure that refresh
958
+ * won't fix — surface it instead of burning a refresh token.
959
+ */
960
+ async shouldRefreshAndRetryParsed(status, parsed, opts) {
961
+ if (opts._isRetry) return false;
962
+ if (opts.auth !== "BearerJWT") return false;
963
+ if (status !== 401) return false;
964
+ if (!this.refreshToken && !this.hasRefreshCookie) return false;
965
+ const body = parsed;
966
+ const label = typeof body?.error === "string" ? body.error : void 0;
967
+ if (label !== void 0 && label !== "token_expired" && label !== "invalid_token") {
968
+ return false;
969
+ }
970
+ try {
971
+ await this.refreshAccessToken();
972
+ return true;
973
+ } catch {
974
+ return false;
975
+ }
976
+ }
977
+ /**
978
+ * Resolve which base URL to call. Default is accounts (`baseUrl`). When a
979
+ * method opts into 'chain', `chainBaseUrl` must be configured — throw at call
980
+ * time with a clear message rather than fall back silently to accounts (no
981
+ * defaults: a wrong base URL is a real bug and would mask itself as a 404).
982
+ */
983
+ resolveBaseUrl(service) {
984
+ if (service === "chain") {
985
+ if (!this.chainBaseUrl) {
986
+ throw new WitniumchainApiError({
987
+ status: 0,
988
+ message: 'WitniumchainClient: chain-api method called without `chainBaseUrl` configured. Pass `chainBaseUrl: "https://api.witniumchain.com"` (or your environment\'s chain-api URL) to the client constructor.'
989
+ });
990
+ }
991
+ return this.chainBaseUrl;
992
+ }
993
+ return this.baseUrl;
994
+ }
995
+ buildPathWithQuery(path, query) {
996
+ if (!query) return path;
385
997
  const qs = new URLSearchParams();
386
998
  for (const [k, v] of Object.entries(query)) {
387
999
  if (v !== void 0) qs.set(k, String(v));
388
1000
  }
389
1001
  const suffix = qs.toString();
390
- return suffix ? `${this.baseUrl}${path}?${suffix}` : `${this.baseUrl}${path}`;
1002
+ return suffix ? `${path}?${suffix}` : path;
391
1003
  }
392
1004
  async applyAuth(headers, auth, method, path, bodyString) {
393
1005
  switch (auth) {
@@ -400,18 +1012,18 @@ var WitniumAccountsClient = class {
400
1012
  return;
401
1013
  }
402
1014
  case "BearerJWT": {
403
- if (!this.cfg.accessToken) {
1015
+ if (!this.accessToken) {
404
1016
  throw new Error(
405
- `WitniumAccountsClient: ${method} ${path} requires an OAuth access token. Pass \`accessToken\` to the constructor.`
1017
+ `WitniumchainClient: ${method} ${path} requires an OAuth access token. Pass \`accessToken\` to the constructor, or call \`beginOAuthLogin\`/\`completeOAuthLogin\` to obtain one.`
406
1018
  );
407
1019
  }
408
- headers["authorization"] = `Bearer ${this.cfg.accessToken}`;
1020
+ headers["authorization"] = `Bearer ${this.accessToken}`;
409
1021
  return;
410
1022
  }
411
1023
  case "OrgApiKey": {
412
1024
  if (!this.cfg.orgApiKey) {
413
1025
  throw new Error(
414
- `WitniumAccountsClient: ${method} ${path} requires an organisation API key. Pass \`orgApiKey\` to the constructor.`
1026
+ `WitniumchainClient: ${method} ${path} requires an organisation API key. Pass \`orgApiKey\` to the constructor.`
415
1027
  );
416
1028
  }
417
1029
  headers["authorization"] = `Bearer ${this.cfg.orgApiKey}`;
@@ -420,7 +1032,7 @@ var WitniumAccountsClient = class {
420
1032
  case "AdminToken": {
421
1033
  if (!this.cfg.adminToken) {
422
1034
  throw new Error(
423
- `WitniumAccountsClient: ${method} ${path} requires an admin token. Pass \`adminToken\` to the constructor.`
1035
+ `WitniumchainClient: ${method} ${path} requires an admin token. Pass \`adminToken\` to the constructor.`
424
1036
  );
425
1037
  }
426
1038
  headers["authorization"] = `Bearer ${this.cfg.adminToken}`;
@@ -429,7 +1041,7 @@ var WitniumAccountsClient = class {
429
1041
  case "SignedRequest": {
430
1042
  if (!this.cfg.signedRequest) {
431
1043
  throw new Error(
432
- `WitniumAccountsClient: ${method} ${path} requires a signed-request signer. Pass \`signedRequest\` to the constructor.`
1044
+ `WitniumchainClient: ${method} ${path} requires a signed-request signer. Pass \`signedRequest\` to the constructor.`
433
1045
  );
434
1046
  }
435
1047
  const timestamp = Math.floor(Date.now() / 1e3).toString();
@@ -451,7 +1063,7 @@ ${bodyHash}`;
451
1063
  parseApiError(status, parsed, rawText) {
452
1064
  const body = parsed;
453
1065
  const message = Array.isArray(body?.message) ? body.message.join("; ") : typeof body?.message === "string" ? body.message : body?.error ?? `HTTP ${status}`;
454
- return new WitniumAccountsApiError({
1066
+ return new WitniumchainApiError({
455
1067
  status,
456
1068
  message,
457
1069
  errorLabel: body?.error,
@@ -511,7 +1123,7 @@ var DelegatedKeys = class {
511
1123
  * budget elapses). The server mints the delegated key in Vault; the caller
512
1124
  * never sees its private key.
513
1125
  *
514
- * Failure modes that surface as thrown {@link WitniumAccountsApiError}:
1126
+ * Failure modes that surface as thrown {@link WitniumchainApiError}:
515
1127
  * - 409 from prepare → an active key already exists for this contract;
516
1128
  * caller must revoke the existing one first.
517
1129
  * - 400 from submit → ownerSignature didn't verify against the prepared
@@ -563,7 +1175,7 @@ var SigningKeys = class {
563
1175
  /**
564
1176
  * The signing keys attached to the calling user's contract. There is no
565
1177
  * dedicated list endpoint; this method calls {@link
566
- * WitniumAccountsClient.getAccount} and returns the `signingKeys` slice.
1178
+ * WitniumchainClient.getAccount} and returns the `signingKeys` slice.
567
1179
  */
568
1180
  async list() {
569
1181
  const account = await this.client.getAccount();
@@ -597,23 +1209,77 @@ var OauthSessions = class {
597
1209
  return this.client.revokeAllOauthSessions();
598
1210
  }
599
1211
  };
1212
+ var MfaNamespace = class {
1213
+ totp;
1214
+ recoveryCodes;
1215
+ constructor(client) {
1216
+ this.totp = new MfaTotp(client);
1217
+ this.recoveryCodes = new MfaRecoveryCodes(client);
1218
+ }
1219
+ };
1220
+ var MfaTotp = class {
1221
+ constructor(client) {
1222
+ this.client = client;
1223
+ }
1224
+ client;
1225
+ /**
1226
+ * Start enrolment. Returns the secret + otpauth URL — render the URL as a
1227
+ * QR code in your dashboard (any QR library will do; the SDK doesn't bundle
1228
+ * one). The enrolment is NOT yet a usable second factor: call
1229
+ * {@link confirm} with the first 6-digit code from the authenticator app
1230
+ * to activate it AND receive the recovery codes.
1231
+ */
1232
+ enroll() {
1233
+ return this.client.enrollTotp();
1234
+ }
1235
+ /**
1236
+ * Confirm enrolment with the first user-supplied code. Returns the 10
1237
+ * single-use recovery codes — show them to the user ONCE; the server never
1238
+ * returns them again. Throws `WitniumchainApiError` with status 400 when
1239
+ * the code is invalid or the enrolment is already confirmed.
1240
+ */
1241
+ confirm(code) {
1242
+ return this.client.confirmTotp({ code });
1243
+ }
1244
+ /** Disable TOTP, wiping both the secret and all recovery codes. */
1245
+ disable() {
1246
+ return this.client.disableTotp();
1247
+ }
1248
+ };
1249
+ var MfaRecoveryCodes = class {
1250
+ constructor(client) {
1251
+ this.client = client;
1252
+ }
1253
+ client;
1254
+ /**
1255
+ * Issue a fresh set of 10 recovery codes, invalidating the prior ones.
1256
+ * Same as `confirm` — codes are returned ONCE and never readable again.
1257
+ */
1258
+ regenerate() {
1259
+ return this.client.regenerateRecoveryCodes();
1260
+ }
1261
+ };
600
1262
  function sleep(ms) {
601
1263
  return new Promise((resolve) => setTimeout(resolve, ms));
602
1264
  }
603
- function randomUUID() {
604
- const c = globalThis.crypto;
605
- if (!c?.randomUUID) {
606
- throw new Error(
607
- "WitniumAccountsClient: globalThis.crypto.randomUUID is required (Node 19+ or modern browser). Polyfill for older runtimes."
608
- );
609
- }
610
- return c.randomUUID();
1265
+ function stableStringify(value) {
1266
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
1267
+ if (Array.isArray(value)) {
1268
+ return "[" + value.map(stableStringify).join(",") + "]";
1269
+ }
1270
+ const obj = value;
1271
+ const keys = Object.keys(obj).sort();
1272
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
1273
+ }
1274
+ async function deriveBodyKey(namespace, contractAddress, body) {
1275
+ const canonical = `${namespace}|${contractAddress.toLowerCase()}|${stableStringify(body)}`;
1276
+ return await sha256Hex(canonical);
611
1277
  }
612
1278
  async function sha256Hex(input) {
613
1279
  const subtle = globalThis.crypto?.subtle;
614
1280
  if (!subtle) {
615
1281
  throw new Error(
616
- "WitniumAccountsClient: SubtleCrypto is not available. Polyfill `globalThis.crypto.subtle` for SignedRequest auth."
1282
+ "WitniumchainClient: SubtleCrypto is not available. Polyfill `globalThis.crypto.subtle` for SignedRequest auth."
617
1283
  );
618
1284
  }
619
1285
  const data = new TextEncoder().encode(input);
@@ -625,13 +1291,13 @@ async function sha256Hex(input) {
625
1291
  }
626
1292
 
627
1293
  // src/admin-client.ts
628
- var WitniumAccountsAdminClient = class {
1294
+ var WitniumchainAdminClient = class {
629
1295
  inner;
630
1296
  constructor(config) {
631
1297
  if (!config.adminToken) {
632
- throw new Error("WitniumAccountsAdminClient: adminToken is required");
1298
+ throw new Error("WitniumchainAdminClient: adminToken is required");
633
1299
  }
634
- this.inner = new WitniumAccountsClient({
1300
+ this.inner = new WitniumchainClient({
635
1301
  baseUrl: config.baseUrl,
636
1302
  adminToken: config.adminToken,
637
1303
  timeout: config.timeout,
@@ -679,15 +1345,15 @@ var WitniumAccountsAdminClient = class {
679
1345
  };
680
1346
 
681
1347
  // src/org-client.ts
682
- var WitniumAccountsOrgClient = class {
1348
+ var WitniumchainOrgClient = class {
683
1349
  inner;
684
1350
  /** User-management namespace — `client.users.create/list`. */
685
1351
  users;
686
1352
  constructor(config) {
687
1353
  if (!config.orgApiKey) {
688
- throw new Error("WitniumAccountsOrgClient: orgApiKey is required");
1354
+ throw new Error("WitniumchainOrgClient: orgApiKey is required");
689
1355
  }
690
- this.inner = new WitniumAccountsClient({
1356
+ this.inner = new WitniumchainClient({
691
1357
  baseUrl: config.baseUrl,
692
1358
  orgApiKey: config.orgApiKey,
693
1359
  timeout: config.timeout,
@@ -724,6 +1390,6 @@ var OrgUsers = class {
724
1390
  }
725
1391
  };
726
1392
 
727
- export { DelegatedKeys, OauthNamespace, OauthSessions, OrgUsers, SigningKeys, Subscriptions, WitniumAccountsAdminClient, WitniumAccountsApiError, WitniumAccountsClient, WitniumAccountsOrgClient };
1393
+ export { DelegatedKeys, MfaNamespace, MfaRecoveryCodes, MfaTotp, OauthNamespace, OauthSessions, OrgUsers, SigningKeys, Subscriptions, WitniumchainAdminClient, WitniumchainApiError, WitniumchainClient, WitniumchainOrgClient, defaultVerifierStorage };
728
1394
  //# sourceMappingURL=index.mjs.map
729
1395
  //# sourceMappingURL=index.mjs.map