@empereur-rouge/pms-sdk 0.5.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -118,6 +118,7 @@ Le client permet d'interagir avec l'API REST des nœuds PMS.
118
118
  const client = new PmsClient({
119
119
  nodeUrl: "https://node.pms.network", // URL du nœud principal
120
120
  apiKey: "pk_live_votre_cle_ici", // Clé API (obligatoire)
121
+ adminToken: "admin_token_ici", // Optionnel: requis pour les méthodes admin (gouvernance, setConfig)
121
122
  seedNodes: [ // Optionnel: nœuds de secours
122
123
  "https://node2.pms.network",
123
124
  "https://node3.pms.network",
@@ -132,6 +133,9 @@ const client = new PmsClient({
132
133
  > [!IMPORTANT]
133
134
  > La clé API est **obligatoire**. Obtenez-la depuis votre dashboard PMS ou auprès de l'administrateur du réseau.
134
135
 
136
+ > [!WARNING]
137
+ > `adminToken` n'est requis que pour les méthodes admin (gouvernance `propose`/`enact`/`cancel`, `setConfig`). Il est envoyé en `Authorization: Bearer <adminToken>`, uniquement vers le nœud principal (jamais vers les seeds lors du racing). **Ne jamais l'exposer côté navigateur ni le committer.** Les lectures publiques de gouvernance n'en ont pas besoin.
138
+
135
139
  #### Wallet (Custodial — Server-Side)
136
140
 
137
141
  Ces méthodes créent/restaurent des wallets **côté serveur**. L'adresse retournée est au format Bech32 (ex: `8e1a...`).
@@ -448,6 +452,78 @@ const result = await client.burnNfts({
448
452
 
449
453
  ---
450
454
 
455
+ ### Gouvernance & Émission
456
+
457
+ La gouvernance est ancrée dans le DAG : un changement de config peut être **proposé** (avec un palier et un timelock), puis **enacté** une fois le timelock écoulé, ou **annulé**. Les lectures sont publiques ; les écritures requièrent `adminToken`.
458
+
459
+ #### Lectures publiques (aucun `adminToken` requis)
460
+
461
+ ```typescript
462
+ // Propositions en attente (statut "pending")
463
+ const pending = await client.getGovernancePending();
464
+ // [{ proposal_id, tier, status: "pending", reason, announced_at_ms, enact_after_ms, update, proposal_block_id, enact_block_id, cancel_block_id }]
465
+
466
+ // Propositions terminées (enactées ou annulées)
467
+ const history = await client.getGovernanceHistory();
468
+
469
+ // Blocs de gouvernance ancrés dans le DAG
470
+ const { count, blocks } = await client.getGovernanceBlocks();
471
+ // blocks: [{ block_id, kind: "proposal" | "enact" | "cancel", proposal_id, tier, status, update, reason, announced_at_ms, enact_after_ms }]
472
+ ```
473
+
474
+ #### Actions admin (requièrent `adminToken`)
475
+
476
+ ```typescript
477
+ // Proposer un changement de config (ici, le couloir d'émission)
478
+ const proposal = await client.proposeGovernance({
479
+ update: { SetEmissionCorridor: { ceiling_bps: 1500, floor_bps: 0, target_bps: 1000, epoch_duration_sec: 86400 } },
480
+ tier: "constitution", // "operator" | "policy" | "constitution" (minuscules)
481
+ reason: "raise emission ceiling for Q3",
482
+ });
483
+ // { status: "ok", proposal_id, block_id, tier, announced_at_ms, enact_after_ms }
484
+
485
+ // Enacter une proposition dont le timelock est écoulé
486
+ await client.enactProposal(proposal.proposal_id, "timelock elapsed");
487
+ // { status: "ok", proposal_id, block_id }
488
+
489
+ // Annuler une proposition en attente
490
+ await client.cancelProposal(proposal.proposal_id);
491
+ ```
492
+
493
+ #### `setConfig` — 200 (appliqué) vs 202 (timelocké)
494
+
495
+ Un **durcissement** (ex: réduire un plafond, désactiver le mint) est appliqué instantanément ; un **assouplissement** est placé sous timelock de gouvernance. Le SDK expose les deux cas via une union discriminée sur `applied` — **un 202 n'est jamais traité comme un succès appliqué** :
496
+
497
+ ```typescript
498
+ const r = await client.setConfig({ SetMintEnabled: { enabled: false } });
499
+ if (r.applied) {
500
+ // HTTP 200 — en vigueur immédiatement
501
+ console.log("appliqué:", r.updateApplied, "bloc:", r.enactBlockId);
502
+ } else {
503
+ // HTTP 202 — proposé, PAS encore en vigueur
504
+ console.log("timelocké, enact après", new Date(r.enactAfterMs), "via", r.proposalId);
505
+ }
506
+ ```
507
+
508
+ #### Codes d'erreur stables
509
+
510
+ Le moteur renvoie ses erreurs au format `{ "code": NNNN, "message": "..." }`. Branchez sur le `code` numérique (stable), pas sur le message :
511
+
512
+ ```typescript
513
+ import { ErrorCode, HttpError } from "@empereur-rouge/pms-sdk";
514
+
515
+ try {
516
+ await client.enactProposal(proposalId);
517
+ } catch (e) {
518
+ if (e instanceof HttpError && e.code === ErrorCode.GovernanceRejected) {
519
+ // 3071 — timelock non écoulé, proposition non en attente, ou id inconnu
520
+ }
521
+ }
522
+ // Codes notables : GovernanceRejected (3071), MintDisabled (5031), MissingAuth (1001), InvalidAuth (1002).
523
+ ```
524
+
525
+ ---
526
+
451
527
  ### API Avancée
452
528
 
453
529
  Pour les cas d'usage avancés (construction manuelle de blocs, chiffrement custom), importez depuis le module `advanced` :
package/dist/index.cjs CHANGED
@@ -21,6 +21,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  DAY_MS: () => DAY_MS,
24
+ ErrorCode: () => ErrorCode,
25
+ HttpError: () => HttpError,
24
26
  PmsClient: () => PmsClient,
25
27
  PmsWallet: () => PmsWallet,
26
28
  decryptPayload: () => decryptPayload,
@@ -418,7 +420,25 @@ var DEFAULT_CONFIG = {
418
420
  enableRacing: true,
419
421
  retries: 2,
420
422
  perAttemptTimeoutMs: 4e3,
421
- retryBaseDelayMs: 100
423
+ retryBaseDelayMs: 100,
424
+ // Vide par défaut : les méthodes admin lèvent une erreur explicite si
425
+ // appelées sans jeton (voir PmsClient.requireAdminToken).
426
+ adminToken: ""
427
+ };
428
+ var ErrorCode = {
429
+ /** En-tête d'authentification manquant (admin requis). */
430
+ MissingAuth: 1001,
431
+ /** Jeton d'authentification invalide. */
432
+ InvalidAuth: 1002,
433
+ /**
434
+ * Proposition de gouvernance rejetée (timelock non écoulé, proposition
435
+ * non en attente, ou identifiant inconnu). HTTP 409.
436
+ */
437
+ GovernanceRejected: 3071,
438
+ /** Émission native (mint) désactivée. HTTP 503. */
439
+ MintDisabled: 5031,
440
+ /** Erreur interne générique (dette : handler non encore migré). */
441
+ Internal: 9999
422
442
  };
423
443
 
424
444
  // src/crypto.ts
@@ -617,6 +637,204 @@ var PmsClient = class {
617
637
  return this.fetch("/v1/reserves/latest", void 0, { idempotent: true });
618
638
  }
619
639
  // ═══════════════════════════════════════════════════════════════════════
640
+ // Gouvernance — lectures publiques (GET, sans authentification)
641
+ // ═══════════════════════════════════════════════════════════════════════
642
+ /**
643
+ * Liste les propositions de gouvernance EN ATTENTE (statut `pending`).
644
+ *
645
+ * Lecture publique — aucun `adminToken` requis.
646
+ *
647
+ * @returns Le tableau des propositions en attente (vide si aucune).
648
+ *
649
+ * @example
650
+ * ```typescript
651
+ * const pending = await client.getGovernancePending();
652
+ * for (const p of pending) {
653
+ * console.log(`${p.proposal_id} enacts after ${new Date(p.enact_after_ms)}`);
654
+ * }
655
+ * ```
656
+ */
657
+ async getGovernancePending() {
658
+ const res = await this.fetch(
659
+ "/v1/governance/pending",
660
+ void 0,
661
+ { idempotent: true }
662
+ );
663
+ return res.pending ?? [];
664
+ }
665
+ /**
666
+ * Liste les propositions de gouvernance TERMINÉES (enactées ou annulées).
667
+ *
668
+ * Lecture publique — aucun `adminToken` requis.
669
+ *
670
+ * @returns Le tableau de l'historique des propositions (vide si aucune).
671
+ */
672
+ async getGovernanceHistory() {
673
+ const res = await this.fetch(
674
+ "/v1/governance/history",
675
+ void 0,
676
+ { idempotent: true }
677
+ );
678
+ return res.history ?? [];
679
+ }
680
+ /**
681
+ * Liste les blocs de gouvernance ancrés dans le DAG (un par proposition,
682
+ * enactment, ou annulation).
683
+ *
684
+ * Lecture publique — aucun `adminToken` requis.
685
+ *
686
+ * @returns `{ count, blocks }` — le nombre et la liste des blocs.
687
+ */
688
+ async getGovernanceBlocks() {
689
+ return this.fetch(
690
+ "/v1/governance/blocks",
691
+ void 0,
692
+ { idempotent: true }
693
+ );
694
+ }
695
+ // ═══════════════════════════════════════════════════════════════════════
696
+ // Gouvernance — actions ADMIN (POST, requièrent adminToken)
697
+ // ═══════════════════════════════════════════════════════════════════════
698
+ /**
699
+ * Soumet une proposition de gouvernance ancrée dans le DAG.
700
+ *
701
+ * Méthode ADMIN — requiert `adminToken` dans la config du client
702
+ * (envoyé en `Authorization: Bearer <adminToken>`). Lève une erreur
703
+ * explicite avant tout appel réseau si le jeton est absent.
704
+ *
705
+ * @param req.update - Mise à jour de config à proposer.
706
+ * @param req.tier - Palier de gouvernance (minuscules, voir
707
+ * [`GovernanceTier`]).
708
+ * @param req.reason - Raison libre (optionnelle).
709
+ * @returns `{ status, proposal_id, block_id, tier, announced_at_ms,
710
+ * enact_after_ms }`.
711
+ *
712
+ * @example
713
+ * ```typescript
714
+ * const res = await client.proposeGovernance({
715
+ * update: { SetEmissionCorridor: { ceiling_bps: 1500, floor_bps: 0, target_bps: 1000, epoch_duration_sec: 86400 } },
716
+ * tier: "constitution",
717
+ * reason: "raise emission ceiling for Q3",
718
+ * });
719
+ * console.log(res.proposal_id, "enacts after", new Date(res.enact_after_ms));
720
+ * ```
721
+ */
722
+ async proposeGovernance(req) {
723
+ return this.fetchAdmin("proposeGovernance", "/admin/governance/propose", {
724
+ method: "POST",
725
+ body: JSON.stringify({
726
+ update: req.update,
727
+ tier: req.tier,
728
+ reason: req.reason ?? ""
729
+ })
730
+ });
731
+ }
732
+ /**
733
+ * Enacte une proposition de gouvernance dont le timelock est écoulé.
734
+ *
735
+ * Méthode ADMIN — requiert `adminToken`. Le moteur rejette (HTTP 409,
736
+ * code [`ErrorCode.GovernanceRejected`] = 3071) si le timelock n'est pas
737
+ * écoulé, si la proposition n'est pas en attente, ou si l'identifiant est
738
+ * inconnu.
739
+ *
740
+ * @param proposalId - Identifiant de la proposition à enacter.
741
+ * @param reason - Raison libre (optionnelle).
742
+ * @returns `{ status, proposal_id, block_id }`.
743
+ */
744
+ async enactProposal(proposalId, reason) {
745
+ return this.fetchAdmin(
746
+ "enactProposal",
747
+ `/admin/governance/enact/${encodeURIComponent(proposalId)}`,
748
+ {
749
+ method: "POST",
750
+ body: JSON.stringify({ reason: reason ?? "" })
751
+ }
752
+ );
753
+ }
754
+ /**
755
+ * Annule une proposition de gouvernance en attente.
756
+ *
757
+ * Méthode ADMIN — requiert `adminToken`. Rejet HTTP 409 / code 3071 si la
758
+ * proposition n'est pas en attente ou si l'identifiant est inconnu.
759
+ *
760
+ * @param proposalId - Identifiant de la proposition à annuler.
761
+ * @param reason - Raison libre (optionnelle).
762
+ * @returns `{ status, proposal_id, block_id }`.
763
+ */
764
+ async cancelProposal(proposalId, reason) {
765
+ return this.fetchAdmin(
766
+ "cancelProposal",
767
+ `/admin/governance/cancel/${encodeURIComponent(proposalId)}`,
768
+ {
769
+ method: "POST",
770
+ body: JSON.stringify({ reason: reason ?? "" })
771
+ }
772
+ );
773
+ }
774
+ /**
775
+ * Applique (ou propose) une mise à jour de configuration.
776
+ *
777
+ * Méthode ADMIN — requiert `adminToken`. Le moteur distingue DEUX issues
778
+ * selon le sens du changement, et le SDK les EXPOSE explicitement via une
779
+ * union discriminée sur `applied` — un 202 n'est JAMAIS traité comme un
780
+ * succès appliqué :
781
+ * - **HTTP 200** (durcissement) → `{ applied: true, ... }` : la mise à
782
+ * jour est instantanément en vigueur.
783
+ * - **HTTP 202** (assouplissement) → `{ applied: false, proposalId,
784
+ * enactAfterMs, ... }` : la mise à jour est placée sous timelock de
785
+ * gouvernance, PAS encore appliquée. Elle s'auto-enacte à l'expiration
786
+ * du timelock, ou via [`enactProposal`].
787
+ *
788
+ * @param update - Mise à jour de config (envoyée brute, sans wrapper).
789
+ * @returns Une [`ConfigChangeResult`] discriminée sur `applied`.
790
+ *
791
+ * @example
792
+ * ```typescript
793
+ * const r = await client.setConfig({ SetMintEnabled: { enabled: false } });
794
+ * if (r.applied) {
795
+ * console.log("appliqué instantanément:", r.updateApplied);
796
+ * } else {
797
+ * console.log("timelocké, enact après", new Date(r.enactAfterMs));
798
+ * }
799
+ * ```
800
+ */
801
+ async setConfig(update) {
802
+ const { status, body } = await this.fetchAdminWithStatus(
803
+ "setConfig",
804
+ "/admin/config",
805
+ {
806
+ method: "POST",
807
+ body: JSON.stringify(update)
808
+ }
809
+ );
810
+ const isApplied = status === 200 && body.status !== "proposed";
811
+ if (isApplied) {
812
+ return {
813
+ applied: true,
814
+ status: "applied",
815
+ mode: body.mode ?? "",
816
+ updateApplied: body.update_applied ?? "",
817
+ tier: body.tier ?? "",
818
+ proposalId: body.proposal_id ?? "",
819
+ proposalBlockId: body.proposal_block_id ?? "",
820
+ enactBlockId: body.enact_block_id ?? "",
821
+ config: body.config ?? null
822
+ };
823
+ }
824
+ return {
825
+ applied: false,
826
+ status: "proposed",
827
+ mode: body.mode ?? "",
828
+ update: body.update ?? "",
829
+ tier: body.tier ?? "",
830
+ proposalId: body.proposal_id ?? "",
831
+ proposalBlockId: body.proposal_block_id ?? "",
832
+ announcedAtMs: body.announced_at_ms ?? 0,
833
+ enactAfterMs: body.enact_after_ms ?? 0,
834
+ message: body.message ?? ""
835
+ };
836
+ }
837
+ // ═══════════════════════════════════════════════════════════════════════
620
838
  // Wallet API (Custodial — Server-Side)
621
839
  // ═══════════════════════════════════════════════════════════════════════
622
840
  /**
@@ -1439,6 +1657,62 @@ var PmsClient = class {
1439
1657
  // ═══════════════════════════════════════════════════════════════════════
1440
1658
  // Helper HTTP
1441
1659
  // ═══════════════════════════════════════════════════════════════════════
1660
+ /**
1661
+ * @internal
1662
+ * Garantit qu'un `adminToken` est configuré avant un appel admin.
1663
+ * Lève une erreur claire (sans appel réseau) sinon.
1664
+ */
1665
+ requireAdminToken(method) {
1666
+ const token = this.config.adminToken;
1667
+ if (!token || token.trim() === "") {
1668
+ throw new Error(
1669
+ `PmsClient.${method}() requires an admin token. Set it via new PmsClient({ ..., adminToken: '<token>' }). Admin methods send it as 'Authorization: Bearer <adminToken>'. Public reads (getGovernancePending/History/Blocks) do NOT need it.`
1670
+ );
1671
+ }
1672
+ return token;
1673
+ }
1674
+ /**
1675
+ * @internal
1676
+ * Effectue un appel admin authentifié (en-tête `Authorization: Bearer`).
1677
+ *
1678
+ * Les écritures admin ne sont PAS idempotentes — pas de retry automatique
1679
+ * (single-attempt via la branche non-idempotente de `fetchUrl`). L'auth est
1680
+ * injectée par appel (pas dans le bloc d'en-têtes partagé de `fetchOnce`)
1681
+ * pour éviter de fuiter le jeton admin vers des nœuds découverts/seeds lors
1682
+ * du racing — cf. `submitBlockRacing`. `method` ne sert qu'au libellé de
1683
+ * l'erreur "jeton manquant".
1684
+ */
1685
+ async fetchAdmin(method, path, init) {
1686
+ return this.fetchUrl(this.config.nodeUrl, path, this.withAdminAuth(method, init));
1687
+ }
1688
+ /**
1689
+ * @internal
1690
+ * Variante d'appel admin qui expose le STATUT HTTP en plus du corps parsé.
1691
+ * Nécessaire pour [`setConfig`], qui doit distinguer 200 (applied) de 202
1692
+ * (proposed) — deux corps de réponse différents pour deux issues. Délègue à
1693
+ * la même primitive `fetchOnceWithStatus` que tous les autres appels, donc
1694
+ * hérite du merge de signal et du tagging d'abort sans duplication.
1695
+ */
1696
+ async fetchAdminWithStatus(method, path, init) {
1697
+ const url = `${this.config.nodeUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`;
1698
+ const auth = this.withAdminAuth(method, init);
1699
+ return this.fetchOnceWithStatus(url, auth, this.config.timeout, auth.signal ?? void 0);
1700
+ }
1701
+ /**
1702
+ * @internal
1703
+ * Vérifie la présence du jeton admin (sinon erreur claire, sans réseau) et
1704
+ * injecte l'en-tête `Authorization: Bearer <token>` dans un RequestInit.
1705
+ */
1706
+ withAdminAuth(method, init) {
1707
+ const token = this.requireAdminToken(method);
1708
+ return {
1709
+ ...init,
1710
+ headers: {
1711
+ ...init?.headers,
1712
+ "Authorization": `Bearer ${token}`
1713
+ }
1714
+ };
1715
+ }
1442
1716
  async fetch(path, init, opts) {
1443
1717
  return this.fetchUrl(this.config.nodeUrl, path, init, opts);
1444
1718
  }
@@ -1446,8 +1720,26 @@ var PmsClient = class {
1446
1720
  * Single-attempt HTTP request. Throws a tagged error on transient conditions
1447
1721
  * (network failure, per-attempt abort, 5xx) so the retry loop can decide
1448
1722
  * whether to retry. 4xx and other non-2xx are thrown as fatal HttpError.
1723
+ *
1724
+ * Thin wrapper over `fetchOnceWithStatus` that discards the HTTP status —
1725
+ * every caller that only needs the parsed body goes through here.
1449
1726
  */
1450
1727
  async fetchOnce(url, init, attemptTimeoutMs, outerSignal) {
1728
+ return (await this.fetchOnceWithStatus(url, init, attemptTimeoutMs, outerSignal)).body;
1729
+ }
1730
+ /**
1731
+ * Single-attempt HTTP request returning BOTH the numeric HTTP status and the
1732
+ * parsed body. The shared fetch primitive: applies the default headers
1733
+ * (`Content-Type`, public `X-API-Key`), merges the caller's signal with the
1734
+ * per-attempt timeout, tags per-attempt aborts as `PerAttemptTimeoutError`
1735
+ * (so the retry loop can act on them), and constructs `HttpError` on non-2xx.
1736
+ *
1737
+ * NOTE: admin `Authorization: Bearer` is NOT injected here — it is added
1738
+ * per-admin-call by `withAdminAuth` so the admin token never leaks to
1739
+ * discovered/seed nodes during `submitBlockRacing`. Only the public,
1740
+ * scoped `X-API-Key` is broadcast.
1741
+ */
1742
+ async fetchOnceWithStatus(url, init, attemptTimeoutMs, outerSignal) {
1451
1743
  const attemptController = new AbortController();
1452
1744
  const attemptTimer = setTimeout(() => attemptController.abort(), attemptTimeoutMs);
1453
1745
  const signal = mergeSignalsAny(attemptController.signal, outerSignal);
@@ -1466,14 +1758,14 @@ var PmsClient = class {
1466
1758
  if (!res.ok) {
1467
1759
  const text2 = await res.text().catch(() => "");
1468
1760
  const retryAfter = res.headers?.get?.("Retry-After") ?? null;
1469
- throw new HttpError(res.status, `HTTP ${res.status} (${url}): ${text2}`, retryAfter);
1761
+ throw new HttpError(res.status, `HTTP ${res.status} (${url}): ${text2}`, retryAfter, text2);
1470
1762
  }
1471
1763
  const text = await res.text();
1472
1764
  if (!text) {
1473
- return {};
1765
+ return { status: res.status, body: {} };
1474
1766
  }
1475
1767
  try {
1476
- return JSON.parse(text);
1768
+ return { status: res.status, body: JSON.parse(text) };
1477
1769
  } catch {
1478
1770
  throw new Error(`Invalid JSON response: ${text.substring(0, 50)}...`);
1479
1771
  }
@@ -1536,11 +1828,30 @@ var PmsClient = class {
1536
1828
  var HttpError = class extends Error {
1537
1829
  status;
1538
1830
  retryAfter;
1539
- constructor(status, message, retryAfter) {
1831
+ /** Raw response body text (may be empty). */
1832
+ body;
1833
+ /** Stable numeric error code parsed from `{code,message}`, if present. */
1834
+ code;
1835
+ /** Engine-provided message from `{code,message}`, if present. */
1836
+ engineMessage;
1837
+ constructor(status, message, retryAfter, body = "") {
1540
1838
  super(message);
1541
1839
  this.name = "HttpError";
1542
1840
  this.status = status;
1543
1841
  this.retryAfter = retryAfter;
1842
+ this.body = body;
1843
+ if (body) {
1844
+ try {
1845
+ const parsed = JSON.parse(body);
1846
+ if (parsed && typeof parsed === "object" && typeof parsed.code === "number") {
1847
+ this.code = parsed.code;
1848
+ if (typeof parsed.message === "string") {
1849
+ this.engineMessage = parsed.message;
1850
+ }
1851
+ }
1852
+ } catch {
1853
+ }
1854
+ }
1544
1855
  }
1545
1856
  };
1546
1857
  var PerAttemptTimeoutError = class extends Error {
@@ -1610,6 +1921,8 @@ function mergeAbortSignals(a, b) {
1610
1921
  // Annotate the CommonJS export names for ESM import in node:
1611
1922
  0 && (module.exports = {
1612
1923
  DAY_MS,
1924
+ ErrorCode,
1925
+ HttpError,
1613
1926
  PmsClient,
1614
1927
  PmsWallet,
1615
1928
  decryptPayload,