@openzeppelin/guardian-operator-client 0.14.3 → 0.14.7

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/http.js CHANGED
@@ -1,3 +1,145 @@
1
+ /**
2
+ * Set of stable error codes added by feature
3
+ * `005-operator-dashboard-metrics`. Used by {@link parseErrorBody} to
4
+ * narrow the unknown server `code` to the typed
5
+ * {@link DashboardErrorCode} union.
6
+ */
7
+ const DASHBOARD_ERROR_CODES = new Set([
8
+ 'authentication_failed',
9
+ 'account_not_found',
10
+ 'invalid_cursor',
11
+ 'invalid_limit',
12
+ 'invalid_status_filter',
13
+ 'data_unavailable',
14
+ // Snapshot-specific codes (FR-045).
15
+ 'unsupported_for_network',
16
+ 'account_data_unavailable',
17
+ // Server emits SCREAMING_SNAKE_CASE; the TS union surfaces snake_case
18
+ // to match the rest of the DashboardErrorCode vocabulary.
19
+ // `parseErrorBody` does the mapping at the boundary.
20
+ 'insufficient_operator_permission',
21
+ // Same wire-form / TS-form mapping as `insufficient_operator_permission`.
22
+ 'account_paused',
23
+ ]);
24
+ /** Server-emitted wire form for the permission-denial error code. */
25
+ const WIRE_INSUFFICIENT_OPERATOR_PERMISSION = 'GUARDIAN_INSUFFICIENT_OPERATOR_PERMISSION';
26
+ /** Server-emitted wire form for the account-paused error code. */
27
+ const WIRE_ACCOUNT_PAUSED = 'GUARDIAN_ACCOUNT_PAUSED';
28
+ /**
29
+ * Map a server-emitted error `code` to the typed
30
+ * {@link DashboardErrorCode} surface. The only mapping today is for
31
+ * the permission-denial code; every other code matches by string
32
+ * equality.
33
+ */
34
+ function mapDashboardErrorCode(raw) {
35
+ if (raw === WIRE_INSUFFICIENT_OPERATOR_PERMISSION) {
36
+ return 'insufficient_operator_permission';
37
+ }
38
+ if (raw === WIRE_ACCOUNT_PAUSED) {
39
+ return 'account_paused';
40
+ }
41
+ return raw;
42
+ }
43
+ /**
44
+ * Parse an error response body into a typed `{ code, message }` shape.
45
+ * Clients SHOULD branch on `code` rather than on the HTTP status alone
46
+ * — see FR-028 of `005-operator-dashboard-metrics`.
47
+ *
48
+ * Accepts either a `Response` (fetch result) or a pre-read JSON body.
49
+ * Returns `{ code: null, message: null }` when the body is absent or
50
+ * malformed; never throws.
51
+ */
52
+ export async function parseErrorBody(response) {
53
+ let raw;
54
+ if (response &&
55
+ typeof response === 'object' &&
56
+ 'text' in response &&
57
+ typeof response.text === 'function') {
58
+ // `response.text()` itself can reject when the body has already
59
+ // been consumed or the underlying stream errors. The function's
60
+ // contract says we never throw, so swallow that and treat it the
61
+ // same as an empty body.
62
+ let body;
63
+ try {
64
+ body = await response.text();
65
+ }
66
+ catch {
67
+ return { code: null, message: null };
68
+ }
69
+ if (!body) {
70
+ return { code: null, message: null };
71
+ }
72
+ try {
73
+ raw = JSON.parse(body);
74
+ }
75
+ catch {
76
+ return { code: null, message: null };
77
+ }
78
+ }
79
+ else {
80
+ raw = response;
81
+ }
82
+ if (raw === null || typeof raw !== 'object') {
83
+ return { code: null, message: null };
84
+ }
85
+ const record = raw;
86
+ const codeRaw = record['code'];
87
+ const code = mapDashboardErrorCode(typeof codeRaw === 'string' ? codeRaw : null);
88
+ const messageRaw = record['error'];
89
+ const message = typeof messageRaw === 'string' ? messageRaw : null;
90
+ const retryRaw = record['retry_after_secs'];
91
+ const retryAfterSecs = typeof retryRaw === 'number' && Number.isInteger(retryRaw)
92
+ ? retryRaw
93
+ : undefined;
94
+ // Feature 006-operator-authz FR-016: populate `missingPermissions`
95
+ // and `retryable` only when the server emitted the permission-denial
96
+ // code. Every other code path leaves both fields undefined so the
97
+ // additive envelope extension is invisible to existing parsers. The
98
+ // contract pins `retryable=false` for this code; surface that
99
+ // unconditionally so a non-compliant server can't mislead retry
100
+ // policy code into retrying a permission denial.
101
+ let missingPermissions;
102
+ let retryable;
103
+ let pausedAt;
104
+ let pausedReason;
105
+ if (code === 'insufficient_operator_permission') {
106
+ const missingRaw = record['missing_permissions'];
107
+ if (Array.isArray(missingRaw) &&
108
+ missingRaw.every((v) => typeof v === 'string')) {
109
+ missingPermissions = missingRaw;
110
+ }
111
+ retryable = false;
112
+ }
113
+ else if (code === 'account_paused') {
114
+ const pausedAtRaw = record['paused_at'];
115
+ if (typeof pausedAtRaw === 'string') {
116
+ pausedAt = pausedAtRaw;
117
+ }
118
+ const reasonRaw = record['paused_reason'];
119
+ if (typeof reasonRaw === 'string') {
120
+ pausedReason = reasonRaw;
121
+ }
122
+ else if (reasonRaw === null) {
123
+ pausedReason = null;
124
+ }
125
+ retryable = false;
126
+ }
127
+ return {
128
+ code,
129
+ message,
130
+ retryAfterSecs,
131
+ missingPermissions,
132
+ retryable,
133
+ pausedAt,
134
+ pausedReason,
135
+ };
136
+ }
137
+ /**
138
+ * Type guard narrowing an arbitrary string to {@link DashboardErrorCode}.
139
+ */
140
+ export function isDashboardErrorCode(value) {
141
+ return DASHBOARD_ERROR_CODES.has(value);
142
+ }
1
143
  export class GuardianOperatorHttpError extends Error {
2
144
  status;
3
145
  statusText;
@@ -53,13 +195,166 @@ export class GuardianOperatorHttpClient {
53
195
  async logout() {
54
196
  return this.request(new URL('auth/logout', this.baseUrl), { method: 'POST' }, parseLogoutResponse);
55
197
  }
56
- async listAccounts() {
57
- return this.request(new URL('dashboard/accounts', this.baseUrl), { method: 'GET' }, parseAccountsResponse);
198
+ /**
199
+ * Paginated account list per feature
200
+ * `005-operator-dashboard-metrics` US1 / FR-001..FR-008.
201
+ *
202
+ * **Breaking change vs. `003-operator-account-apis`**: the response
203
+ * is now a {@link PagedResult} envelope. The previous
204
+ * unparameterized full-inventory mode and `total_count` field are
205
+ * removed; aggregate inventory totals are exposed via
206
+ * {@link GuardianOperatorHttpClient.getDashboardInfo}. Callers that
207
+ * relied on `listAccounts()` must migrate to this method.
208
+ */
209
+ async listAccounts(options = {}) {
210
+ const url = new URL('dashboard/accounts', this.baseUrl);
211
+ applyPaginationParams(url, options);
212
+ if (options.paused !== undefined) {
213
+ url.searchParams.set('paused', String(options.paused));
214
+ }
215
+ return this.request(url, { method: 'GET' }, parseAccountListPage);
216
+ }
217
+ /**
218
+ * Return the authenticated operator's identity and effective
219
+ * permission set. Succeeds for any valid session, including
220
+ * operators with `permissions: []` (returns an empty array).
221
+ */
222
+ async getSession() {
223
+ return this.request(new URL('dashboard/session', this.baseUrl), { method: 'GET' }, parseSessionInfo);
224
+ }
225
+ /**
226
+ * Inventory and lifecycle health snapshot per feature
227
+ * `005-operator-dashboard-metrics` US2 / FR-008..FR-012.
228
+ */
229
+ async getDashboardInfo() {
230
+ return this.request(new URL('dashboard/info', this.baseUrl), { method: 'GET' }, parseDashboardInfo);
231
+ }
232
+ /**
233
+ * Pause mutating actions for the given account. Requires
234
+ * `accounts:pause`. Idempotent: re-pausing an already-paused account
235
+ * returns success with the original `pausedAt` preserved.
236
+ */
237
+ async pauseAccount(accountId, reason) {
238
+ const encoded = encodeURIComponent(accountId);
239
+ return this.request(new URL(`dashboard/accounts/${encoded}/pause`, this.baseUrl), { method: 'POST', body: JSON.stringify({ reason }) }, parsePauseResponse);
240
+ }
241
+ /**
242
+ * Clear pause state for the given account. Requires `accounts:pause`.
243
+ * Idempotent: unpausing an already-active account returns success
244
+ * with no state change.
245
+ */
246
+ async unpauseAccount(accountId, reason) {
247
+ const encoded = encodeURIComponent(accountId);
248
+ const init = { method: 'POST' };
249
+ if (reason !== undefined) {
250
+ init.body = JSON.stringify({ reason });
251
+ }
252
+ return this.request(new URL(`dashboard/accounts/${encoded}/unpause`, this.baseUrl), init, parseUnpauseResponse);
58
253
  }
59
254
  async getAccount(accountId) {
60
255
  const encodedAccountId = encodeURIComponent(accountId);
61
256
  return this.request(new URL(`dashboard/accounts/${encodedAccountId}`, this.baseUrl), { method: 'GET' }, parseAccountResponse);
62
257
  }
258
+ /**
259
+ * Return a decoded snapshot of `accountId`'s stored state at the
260
+ * commitment Guardian last canonicalized — v1 surface exposes the
261
+ * fungible/non-fungible vault. The endpoint distinguishes its
262
+ * failure modes via the FR-045 error taxonomy:
263
+ *
264
+ * - `400 unsupported_for_network` — the account's `network_config`
265
+ * is EVM. The snapshot endpoint is Miden-only by construction;
266
+ * this is a permanent condition for the account on this surface,
267
+ * not a transient failure.
268
+ * - `404 account_not_found` — no metadata exists.
269
+ * - `503 account_data_unavailable` — metadata exists but the state
270
+ * row cannot be loaded, or the stored blob fails to deserialize
271
+ * as a Miden `Account`. Transient/recoverable.
272
+ *
273
+ * Spec reference: follow-up addition to `005-operator-dashboard-metrics`
274
+ * FR-043..FR-046.
275
+ */
276
+ async getAccountSnapshot(accountId) {
277
+ const encodedAccountId = encodeURIComponent(accountId);
278
+ return this.request(new URL(`dashboard/accounts/${encodedAccountId}/snapshot`, this.baseUrl), { method: 'GET' }, parseAccountSnapshot);
279
+ }
280
+ /**
281
+ * List the per-account delta feed for `accountId`, paginated
282
+ * newest-first by `nonce DESC`. Spec reference:
283
+ * `005-operator-dashboard-metrics` US3.
284
+ */
285
+ async listAccountDeltas(accountId, options = {}) {
286
+ const encodedAccountId = encodeURIComponent(accountId);
287
+ const url = new URL(`dashboard/accounts/${encodedAccountId}/deltas`, this.baseUrl);
288
+ applyPaginationParams(url, options);
289
+ return this.request(url, { method: 'GET' }, parseDeltaPage);
290
+ }
291
+ /**
292
+ * Fetch the full detail projection of one canonical delta. The
293
+ * nonce is serialized as a canonical base-10 `u64` URL segment;
294
+ * unknown account and unknown nonce both surface as
295
+ * `404 delta_not_found`.
296
+ */
297
+ async getAccountDeltaDetail(accountId, nonce, options = {}) {
298
+ if (!Number.isSafeInteger(nonce) || nonce < 0) {
299
+ throw new GuardianOperatorContractError('getAccountDeltaDetail.nonce', `nonce must be a non-negative safe integer, got ${nonce}`);
300
+ }
301
+ const encodedAccountId = encodeURIComponent(accountId);
302
+ const url = new URL(`dashboard/accounts/${encodedAccountId}/deltas/${nonce.toString()}`, this.baseUrl);
303
+ if (options.includeRaw) {
304
+ url.searchParams.set('include', 'raw');
305
+ }
306
+ return this.request(url, { method: 'GET' }, parseDeltaDetail);
307
+ }
308
+ /**
309
+ * List the per-account in-flight multisig proposal queue for
310
+ * `accountId`, paginated newest-first by `(nonce DESC, commitment
311
+ * DESC)`. Single-key Miden and EVM accounts always return an empty
312
+ * page per FR-017. Spec reference:
313
+ * `005-operator-dashboard-metrics` US4.
314
+ */
315
+ async listAccountProposals(accountId, options = {}) {
316
+ const encodedAccountId = encodeURIComponent(accountId);
317
+ const url = new URL(`dashboard/accounts/${encodedAccountId}/proposals`, this.baseUrl);
318
+ applyPaginationParams(url, options);
319
+ return this.request(url, { method: 'GET' }, parseProposalPage);
320
+ }
321
+ /**
322
+ * Cross-account delta feed. Paginated newest-first by
323
+ * `status_timestamp DESC`. The optional `status` filter accepts a
324
+ * single status or an array; the wrapper serializes the array to
325
+ * comma-separated. Spec reference:
326
+ * `005-operator-dashboard-metrics` US6.
327
+ */
328
+ async listGlobalDeltas(options = {}) {
329
+ const url = new URL('dashboard/deltas', this.baseUrl);
330
+ if (options.limit !== undefined) {
331
+ url.searchParams.set('limit', String(options.limit));
332
+ }
333
+ if (options.cursor !== undefined) {
334
+ url.searchParams.set('cursor', options.cursor);
335
+ }
336
+ if (options.status !== undefined) {
337
+ const statuses = Array.isArray(options.status)
338
+ ? options.status
339
+ : [options.status];
340
+ if (statuses.length > 0) {
341
+ url.searchParams.set('status', statuses.join(','));
342
+ }
343
+ }
344
+ return this.request(url, { method: 'GET' }, parseGlobalDeltasPage);
345
+ }
346
+ /**
347
+ * Cross-account in-flight proposal feed. Paginated newest-first by
348
+ * `originating_timestamp DESC`. Takes no `status` filter — every
349
+ * entry is in-flight by definition. EVM accounts do not appear in
350
+ * v1 per FR-017. Spec reference:
351
+ * `005-operator-dashboard-metrics` US7.
352
+ */
353
+ async listGlobalProposals(options = {}) {
354
+ const url = new URL('dashboard/proposals', this.baseUrl);
355
+ applyPaginationParams(url, options);
356
+ return this.request(url, { method: 'GET' }, parseGlobalProposalsPage);
357
+ }
63
358
  async request(url, init, parse) {
64
359
  const response = await this.fetchImpl(url.toString(), {
65
360
  ...init,
@@ -174,24 +469,138 @@ function parseLogoutResponse(value) {
174
469
  success: requireSuccess(record, 'logout response'),
175
470
  };
176
471
  }
177
- function parseAccountsResponse(value) {
178
- const record = asRecord(value, 'accounts response');
472
+ function parseSessionInfo(value) {
473
+ const record = asRecord(value, 'session response');
474
+ const permissions = requireStringArray(record, 'permissions', 'session response');
475
+ for (const [index, permission] of permissions.entries()) {
476
+ if (!KNOWN_OPERATOR_PERMISSIONS.has(permission)) {
477
+ throw new GuardianOperatorContractError('session response', `permissions[${index}] is not a known operator permission: ${JSON.stringify(permission)}`);
478
+ }
479
+ }
480
+ return {
481
+ operatorId: requireString(record, 'operator_id', 'session response'),
482
+ permissions: permissions,
483
+ };
484
+ }
485
+ /**
486
+ * Stable v1 operator permission vocabulary. Mirrors
487
+ * `crates/server/src/dashboard/permissions.rs::Permission::as_str`.
488
+ * Surfaces server/client drift as a contract failure instead of
489
+ * silently flowing unknown strings through.
490
+ */
491
+ const KNOWN_OPERATOR_PERMISSIONS = new Set([
492
+ 'dashboard:read',
493
+ 'accounts:pause',
494
+ 'policies:write',
495
+ ]);
496
+ function parseAccountListPage(value) {
497
+ return parsePagedResult(value, parseAccountSummary, 'accounts page');
498
+ }
499
+ function parseDashboardInfo(value) {
500
+ const record = asRecord(value, 'dashboard info');
501
+ const serviceStatusRaw = requireString(record, 'service_status', 'dashboard info');
502
+ let serviceStatus;
503
+ if (serviceStatusRaw === 'healthy' || serviceStatusRaw === 'degraded') {
504
+ serviceStatus = serviceStatusRaw;
505
+ }
506
+ else {
507
+ throw new GuardianOperatorContractError('dashboard info', `expected service_status to be "healthy" or "degraded", got ${JSON.stringify(serviceStatusRaw)}`);
508
+ }
509
+ const counts = asRecord(requireField(record, 'delta_status_counts', 'dashboard info'), 'dashboard info.delta_status_counts');
510
+ const buildRecord = asRecord(requireField(record, 'build', 'dashboard info'), 'dashboard info.build');
511
+ const profileRaw = requireString(buildRecord, 'profile', 'dashboard info.build');
512
+ if (profileRaw !== 'debug' && profileRaw !== 'release') {
513
+ throw new GuardianOperatorContractError('dashboard info.build', `expected profile to be "debug" or "release", got ${JSON.stringify(profileRaw)}`);
514
+ }
515
+ const build = {
516
+ version: requireString(buildRecord, 'version', 'dashboard info.build'),
517
+ gitCommit: requireString(buildRecord, 'git_commit', 'dashboard info.build'),
518
+ profile: profileRaw,
519
+ startedAt: requireString(buildRecord, 'started_at', 'dashboard info.build'),
520
+ };
521
+ const backendRecord = asRecord(requireField(record, 'backend', 'dashboard info'), 'dashboard info.backend');
522
+ const storageRaw = requireString(backendRecord, 'storage', 'dashboard info.backend');
523
+ if (storageRaw !== 'filesystem' && storageRaw !== 'postgres') {
524
+ throw new GuardianOperatorContractError('dashboard info.backend', `expected storage to be "filesystem" or "postgres", got ${JSON.stringify(storageRaw)}`);
525
+ }
526
+ const canonicalizationField = backendRecord['canonicalization'];
527
+ let canonicalization;
528
+ if (canonicalizationField === null || canonicalizationField === undefined) {
529
+ canonicalization = null;
530
+ }
531
+ else {
532
+ const c = asRecord(canonicalizationField, 'dashboard info.backend.canonicalization');
533
+ canonicalization = {
534
+ checkIntervalSeconds: requireInteger(c, 'check_interval_seconds', 'dashboard info.backend.canonicalization'),
535
+ maxRetries: requireInteger(c, 'max_retries', 'dashboard info.backend.canonicalization'),
536
+ submissionGracePeriodSeconds: requireInteger(c, 'submission_grace_period_seconds', 'dashboard info.backend.canonicalization'),
537
+ };
538
+ }
539
+ const backend = {
540
+ storage: storageRaw,
541
+ supportedAckSchemes: requireStringArray(backendRecord, 'supported_ack_schemes', 'dashboard info.backend'),
542
+ canonicalization,
543
+ };
544
+ const authMethodsRecord = asRecord(requireField(record, 'accounts_by_auth_method', 'dashboard info'), 'dashboard info.accounts_by_auth_method');
545
+ const accountsByAuthMethod = {};
546
+ for (const [key, raw] of Object.entries(authMethodsRecord)) {
547
+ if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 0) {
548
+ throw new GuardianOperatorContractError('dashboard info.accounts_by_auth_method', `expected non-negative integer count for "${key}", got ${JSON.stringify(raw)}`);
549
+ }
550
+ accountsByAuthMethod[key] = raw;
551
+ }
179
552
  return {
180
- success: requireSuccess(record, 'accounts response'),
181
- totalCount: requireInteger(record, 'total_count', 'accounts response'),
182
- accounts: requireArray(record, 'accounts', 'accounts response').map((entry, index) => parseAccountSummary(entry, `accounts[${index}]`)),
553
+ serviceStatus,
554
+ environment: requireString(record, 'environment', 'dashboard info'),
555
+ build,
556
+ backend,
557
+ totalAccountCount: requireInteger(record, 'total_account_count', 'dashboard info'),
558
+ accountsByAuthMethod,
559
+ latestActivity: requireNullableString(record, 'latest_activity', 'dashboard info'),
560
+ deltaStatusCounts: {
561
+ candidate: requireInteger(counts, 'candidate', 'dashboard info.delta_status_counts'),
562
+ canonical: requireInteger(counts, 'canonical', 'dashboard info.delta_status_counts'),
563
+ discarded: requireInteger(counts, 'discarded', 'dashboard info.delta_status_counts'),
564
+ },
565
+ inFlightProposalCount: requireInteger(record, 'in_flight_proposal_count', 'dashboard info'),
566
+ degradedAggregates: requireStringArray(record, 'degraded_aggregates', 'dashboard info'),
183
567
  };
184
568
  }
185
569
  function parseAccountResponse(value) {
186
- const record = asRecord(value, 'account response');
570
+ return parseAccountDetail(value, 'account response');
571
+ }
572
+ function parseAccountSnapshot(value) {
573
+ const record = asRecord(value, 'account snapshot');
574
+ const vaultRecord = asRecord(requireField(record, 'vault', 'account snapshot'), 'account snapshot.vault');
575
+ const fungibleRaw = requireArray(vaultRecord, 'fungible', 'account snapshot.vault');
576
+ const nonFungibleRaw = requireArray(vaultRecord, 'non_fungible', 'account snapshot.vault');
577
+ const fungible = fungibleRaw.map((entry, idx) => {
578
+ const ctx = `account snapshot.vault.fungible[${idx}]`;
579
+ const r = asRecord(entry, ctx);
580
+ return {
581
+ faucetId: requireString(r, 'faucet_id', ctx),
582
+ amount: requireString(r, 'amount', ctx),
583
+ };
584
+ });
585
+ const nonFungible = nonFungibleRaw.map((entry, idx) => {
586
+ const ctx = `account snapshot.vault.non_fungible[${idx}]`;
587
+ const r = asRecord(entry, ctx);
588
+ return {
589
+ faucetId: requireString(r, 'faucet_id', ctx),
590
+ vaultKey: requireString(r, 'vault_key', ctx),
591
+ };
592
+ });
593
+ const vault = { fungible, nonFungible };
187
594
  return {
188
- success: requireSuccess(record, 'account response'),
189
- account: parseAccountDetail(requireField(record, 'account', 'account response'), 'account'),
595
+ commitment: requireString(record, 'commitment', 'account snapshot'),
596
+ updatedAt: requireString(record, 'updated_at', 'account snapshot'),
597
+ hasPendingCandidate: requireBoolean(record, 'has_pending_candidate', 'account snapshot'),
598
+ vault,
190
599
  };
191
600
  }
192
601
  function parseAccountSummary(value, context) {
193
602
  const record = asRecord(value, context);
194
- return {
603
+ const summary = {
195
604
  accountId: requireString(record, 'account_id', context),
196
605
  authScheme: requireString(record, 'auth_scheme', context),
197
606
  authorizedSignerCount: requireInteger(record, 'authorized_signer_count', context),
@@ -200,7 +609,16 @@ function parseAccountSummary(value, context) {
200
609
  stateStatus: parseStateStatus(requireString(record, 'state_status', context), `${context}.state_status`),
201
610
  createdAt: requireString(record, 'created_at', context),
202
611
  updatedAt: requireString(record, 'updated_at', context),
612
+ pausedAt: requireNullableString(record, 'paused_at', context),
613
+ pausedReason: requireNullableString(record, 'paused_reason', context),
203
614
  };
615
+ if (record.account_id_bech32 !== undefined && record.account_id_bech32 !== null) {
616
+ if (typeof record.account_id_bech32 !== 'string') {
617
+ throw new GuardianOperatorContractError(context, 'account_id_bech32 must be a string when present');
618
+ }
619
+ summary.accountIdBech32 = record.account_id_bech32;
620
+ }
621
+ return summary;
204
622
  }
205
623
  function parseAccountDetail(value, context) {
206
624
  const summary = parseAccountSummary(value, context);
@@ -212,6 +630,33 @@ function parseAccountDetail(value, context) {
212
630
  stateUpdatedAt: requireNullableString(record, 'state_updated_at', context),
213
631
  };
214
632
  }
633
+ function parseAccountStatus(value, context) {
634
+ if (value === 'active' || value === 'paused') {
635
+ return value;
636
+ }
637
+ throw new GuardianOperatorContractError(context, `expected account status "active" or "paused", got ${JSON.stringify(value)}`);
638
+ }
639
+ function parsePauseResponse(value) {
640
+ const ctx = 'pause response';
641
+ const record = asRecord(value, ctx);
642
+ return {
643
+ accountId: requireString(record, 'account_id', ctx),
644
+ beforeState: parseAccountStatus(requireString(record, 'before_state', ctx), `${ctx}.before_state`),
645
+ afterState: parseAccountStatus(requireString(record, 'after_state', ctx), `${ctx}.after_state`),
646
+ pausedAt: requireString(record, 'paused_at', ctx),
647
+ pausedReason: requireString(record, 'paused_reason', ctx),
648
+ };
649
+ }
650
+ function parseUnpauseResponse(value) {
651
+ const ctx = 'unpause response';
652
+ const record = asRecord(value, ctx);
653
+ return {
654
+ accountId: requireString(record, 'account_id', ctx),
655
+ beforeState: parseAccountStatus(requireString(record, 'before_state', ctx), `${ctx}.before_state`),
656
+ afterState: parseAccountStatus(requireString(record, 'after_state', ctx), `${ctx}.after_state`),
657
+ reason: requireNullableString(record, 'reason', ctx),
658
+ };
659
+ }
215
660
  function parseErrorResponse(value) {
216
661
  const record = asRecord(value, 'error response');
217
662
  const success = requireBoolean(record, 'success', 'error response');
@@ -226,10 +671,74 @@ function parseErrorResponse(value) {
226
671
  }
227
672
  retryAfterSecs = retryAfterValue;
228
673
  }
674
+ // Optional stable machine-readable error code added by feature
675
+ // `005-operator-dashboard-metrics` and required for the dashboard
676
+ // error taxonomy (FR-028). Older servers may omit it; tolerate that
677
+ // by leaving the field undefined.
678
+ const codeValue = record.code;
679
+ let code;
680
+ if (codeValue !== undefined) {
681
+ if (typeof codeValue !== 'string') {
682
+ throw new GuardianOperatorContractError('error response', 'code must be a string when present');
683
+ }
684
+ code = mapDashboardErrorCode(codeValue) ?? undefined;
685
+ }
686
+ // Feature 006-operator-authz FR-016: populate `missingPermissions`
687
+ // and `retryable` only on the permission-denial code. Tolerate
688
+ // either ordering or omission on every other code so legacy 5xx /
689
+ // 4xx errors continue to parse byte-for-byte as before.
690
+ let missingPermissions;
691
+ let retryable;
692
+ let pausedAt;
693
+ let pausedReason;
694
+ if (code === 'insufficient_operator_permission') {
695
+ const missingRaw = record.missing_permissions;
696
+ if (missingRaw !== undefined) {
697
+ if (!Array.isArray(missingRaw) ||
698
+ !missingRaw.every((v) => typeof v === 'string')) {
699
+ throw new GuardianOperatorContractError('error response', 'missing_permissions must be an array of strings');
700
+ }
701
+ missingPermissions = missingRaw;
702
+ }
703
+ const retryableRaw = record.retryable;
704
+ if (retryableRaw !== undefined) {
705
+ if (retryableRaw !== false) {
706
+ // FR-016 pins `retryable: false` for permission denials.
707
+ // Surface server contract drift loudly rather than letting
708
+ // retry policy code retry an unretryable failure.
709
+ throw new GuardianOperatorContractError('error response', 'retryable must be false for insufficient_operator_permission');
710
+ }
711
+ retryable = false;
712
+ }
713
+ }
714
+ else if (code === 'account_paused') {
715
+ const pausedAtRaw = record.paused_at;
716
+ if (typeof pausedAtRaw !== 'string') {
717
+ throw new GuardianOperatorContractError('error response', 'paused_at must be a string for account_paused');
718
+ }
719
+ pausedAt = pausedAtRaw;
720
+ const reasonRaw = record.paused_reason;
721
+ if (typeof reasonRaw === 'string' || reasonRaw === null) {
722
+ pausedReason = reasonRaw;
723
+ }
724
+ else if (reasonRaw !== undefined) {
725
+ throw new GuardianOperatorContractError('error response', 'paused_reason must be a string or null for account_paused');
726
+ }
727
+ const retryableRaw = record.retryable;
728
+ if (retryableRaw !== undefined && retryableRaw !== false) {
729
+ throw new GuardianOperatorContractError('error response', 'retryable must be false for account_paused');
730
+ }
731
+ retryable = false;
732
+ }
229
733
  return {
230
734
  success: false,
735
+ code,
231
736
  error: requireString(record, 'error', 'error response'),
232
737
  retryAfterSecs,
738
+ missingPermissions,
739
+ retryable,
740
+ pausedAt,
741
+ pausedReason,
233
742
  };
234
743
  }
235
744
  function parseStateStatus(value, context) {
@@ -303,4 +812,393 @@ function requireStringArray(record, key, context) {
303
812
  return entry;
304
813
  });
305
814
  }
815
+ function requireNonNegativeInteger(value, key, context) {
816
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
817
+ throw new GuardianOperatorContractError(context, `field "${key}" must be a non-negative integer`);
818
+ }
819
+ return value;
820
+ }
821
+ function assertStringArray(value, key, context) {
822
+ value.forEach((entry, index) => {
823
+ if (typeof entry !== 'string') {
824
+ throw new GuardianOperatorContractError(context, `field "${key}" entry ${index} must be a string`);
825
+ }
826
+ });
827
+ return value;
828
+ }
829
+ // ---------------------------------------------------------------------------
830
+ // Pagination helpers — feature `005-operator-dashboard-metrics`.
831
+ // ---------------------------------------------------------------------------
832
+ function applyPaginationParams(url, options) {
833
+ if (options.limit !== undefined) {
834
+ url.searchParams.set('limit', String(options.limit));
835
+ }
836
+ if (options.cursor !== undefined) {
837
+ url.searchParams.set('cursor', options.cursor);
838
+ }
839
+ }
840
+ function parseDeltaPage(value) {
841
+ return parsePagedResult(value, parseDeltaEntry, 'deltas page');
842
+ }
843
+ function parseProposalPage(value) {
844
+ return parsePagedResult(value, parseProposalEntry, 'proposals page');
845
+ }
846
+ function parseGlobalDeltasPage(value) {
847
+ return parsePagedResult(value, (entry, ctx) => parseGlobalDeltaEntry(entry, ctx), 'global deltas page');
848
+ }
849
+ function parseGlobalProposalsPage(value) {
850
+ return parsePagedResult(value, (entry, ctx) => parseGlobalProposalEntry(entry, ctx), 'global proposals page');
851
+ }
852
+ function parseGlobalDeltaEntry(value, context) {
853
+ const base = parseDeltaEntry(value, context);
854
+ if (!base.accountId) {
855
+ throw new GuardianOperatorContractError(context, 'global delta feed entries must include account_id');
856
+ }
857
+ return base;
858
+ }
859
+ function parseGlobalProposalEntry(value, context) {
860
+ const base = parseProposalEntry(value, context);
861
+ if (!base.accountId) {
862
+ throw new GuardianOperatorContractError(context, 'global proposal feed entries must include account_id');
863
+ }
864
+ return base;
865
+ }
866
+ function parsePagedResult(value, parseItem, context) {
867
+ const record = asRecord(value, context);
868
+ const items = requireArray(record, 'items', context).map((entry, index) => parseItem(entry, `${context}.items[${index}]`));
869
+ const nextCursorRaw = requireField(record, 'next_cursor', context);
870
+ let nextCursor;
871
+ if (nextCursorRaw === null) {
872
+ nextCursor = null;
873
+ }
874
+ else if (typeof nextCursorRaw === 'string') {
875
+ nextCursor = nextCursorRaw;
876
+ }
877
+ else {
878
+ throw new GuardianOperatorContractError(context, 'next_cursor must be a string or null');
879
+ }
880
+ return { items, nextCursor };
881
+ }
882
+ function parseDeltaEntry(value, context) {
883
+ const record = asRecord(value, context);
884
+ const entry = {
885
+ nonce: requireInteger(record, 'nonce', context),
886
+ status: parseDeltaStatus(requireString(record, 'status', context), `${context}.status`),
887
+ statusTimestamp: requireString(record, 'status_timestamp', context),
888
+ prevCommitment: requireString(record, 'prev_commitment', context),
889
+ newCommitment: requireNullableString(record, 'new_commitment', context),
890
+ };
891
+ if (record.retry_count !== undefined) {
892
+ const retry = record.retry_count;
893
+ if (typeof retry !== 'number' || !Number.isInteger(retry) || retry < 0) {
894
+ throw new GuardianOperatorContractError(context, 'retry_count must be a non-negative integer when present');
895
+ }
896
+ entry.retryCount = retry;
897
+ }
898
+ if (record.account_id !== undefined) {
899
+ if (typeof record.account_id !== 'string') {
900
+ throw new GuardianOperatorContractError(context, 'account_id must be a string when present');
901
+ }
902
+ entry.accountId = record.account_id;
903
+ }
904
+ if (record.category !== undefined && record.category !== null) {
905
+ entry.category = parseDeltaCategory(requireString(record, 'category', context), `${context}.category`);
906
+ }
907
+ if (record.proposal_type !== undefined) {
908
+ if (typeof record.proposal_type !== 'string') {
909
+ throw new GuardianOperatorContractError(context, 'proposal_type must be a string when present');
910
+ }
911
+ entry.proposalType = record.proposal_type;
912
+ }
913
+ if (record.note_counts !== undefined && record.note_counts !== null) {
914
+ entry.noteCounts = parseDeltaNoteCounts(requireField(record, 'note_counts', context), `${context}.note_counts`);
915
+ }
916
+ if (record.assets !== undefined) {
917
+ entry.assets = parseDeltaAssetSummaryArray(record.assets, `${context}.assets`);
918
+ }
919
+ if (record.counterparty !== undefined && record.counterparty !== null) {
920
+ entry.counterparty = parseDeltaCounterpartySummary(record.counterparty, `${context}.counterparty`);
921
+ }
922
+ return entry;
923
+ }
924
+ function parseProposalEntry(value, context) {
925
+ const record = asRecord(value, context);
926
+ const entry = {
927
+ commitment: requireString(record, 'commitment', context),
928
+ nonce: requireInteger(record, 'nonce', context),
929
+ proposerId: requireString(record, 'proposer_id', context),
930
+ originatingTimestamp: requireString(record, 'originating_timestamp', context),
931
+ signaturesCollected: requireInteger(record, 'signatures_collected', context),
932
+ signaturesRequired: requireInteger(record, 'signatures_required', context),
933
+ prevCommitment: requireString(record, 'prev_commitment', context),
934
+ newCommitment: requireNullableString(record, 'new_commitment', context),
935
+ };
936
+ if (record.account_id !== undefined) {
937
+ if (typeof record.account_id !== 'string') {
938
+ throw new GuardianOperatorContractError(context, 'account_id must be a string when present');
939
+ }
940
+ entry.accountId = record.account_id;
941
+ }
942
+ if (record.proposal_type !== undefined) {
943
+ if (typeof record.proposal_type !== 'string') {
944
+ throw new GuardianOperatorContractError(context, 'proposal_type must be a string when present');
945
+ }
946
+ entry.proposalType = record.proposal_type;
947
+ }
948
+ return entry;
949
+ }
950
+ const DELTA_CATEGORY_VALUES = [
951
+ 'asset_transfer',
952
+ 'note_consumption',
953
+ 'note_creation',
954
+ 'account_storage_change',
955
+ 'guardian_switch',
956
+ 'custom',
957
+ ];
958
+ function parseDeltaCategory(value, context) {
959
+ if (DELTA_CATEGORY_VALUES.includes(value)) {
960
+ return value;
961
+ }
962
+ throw new GuardianOperatorContractError(context, `invalid category "${value}" — expected one of ${DELTA_CATEGORY_VALUES.join(', ')}`);
963
+ }
964
+ function parseDeltaAssetSummary(value, context) {
965
+ const record = asRecord(value, context);
966
+ const kind = requireString(record, 'kind', context);
967
+ if (kind !== 'fungible' && kind !== 'non_fungible') {
968
+ throw new GuardianOperatorContractError(context, `asset kind must be "fungible" or "non_fungible", got "${kind}"`);
969
+ }
970
+ const asset = {
971
+ assetId: requireString(record, 'asset_id', context),
972
+ kind: kind,
973
+ };
974
+ if (record.amount !== undefined && record.amount !== null) {
975
+ if (typeof record.amount !== 'string') {
976
+ throw new GuardianOperatorContractError(context, 'asset amount must be a string when present');
977
+ }
978
+ asset.amount = record.amount;
979
+ }
980
+ return asset;
981
+ }
982
+ function parseDeltaAssetSummaryArray(value, context) {
983
+ if (!Array.isArray(value)) {
984
+ throw new GuardianOperatorContractError(context, 'expected an array');
985
+ }
986
+ return value.map((item, index) => parseDeltaAssetSummary(item, `${context}[${index}]`));
987
+ }
988
+ function parseDeltaCounterpartySummary(value, context) {
989
+ const record = asRecord(value, context);
990
+ const direction = requireString(record, 'direction', context);
991
+ if (direction !== 'in' && direction !== 'out') {
992
+ throw new GuardianOperatorContractError(context, `counterparty direction must be "in" or "out", got "${direction}"`);
993
+ }
994
+ return {
995
+ accountId: requireString(record, 'account_id', context),
996
+ direction: direction,
997
+ };
998
+ }
999
+ function parseDeltaNoteCounts(value, context) {
1000
+ const record = asRecord(value, context);
1001
+ const input = requireInteger(record, 'input', context);
1002
+ const output = requireInteger(record, 'output', context);
1003
+ if (input < 0 || output < 0) {
1004
+ throw new GuardianOperatorContractError(context, 'note counts must be non-negative integers');
1005
+ }
1006
+ return { input, output };
1007
+ }
1008
+ function parseDeltaProposalMetadata(value, context) {
1009
+ const record = asRecord(value, context);
1010
+ const proposal = {
1011
+ proposalType: requireString(record, 'proposal_type', context),
1012
+ };
1013
+ if (typeof record.description === 'string')
1014
+ proposal.description = record.description;
1015
+ if (typeof record.salt === 'string')
1016
+ proposal.salt = record.salt;
1017
+ if (record.required_signatures !== undefined)
1018
+ proposal.requiredSignatures = requireNonNegativeInteger(record.required_signatures, 'required_signatures', context);
1019
+ if (typeof record.recipient_id === 'string')
1020
+ proposal.recipientId = record.recipient_id;
1021
+ if (typeof record.faucet_id === 'string')
1022
+ proposal.faucetId = record.faucet_id;
1023
+ if (typeof record.amount === 'string')
1024
+ proposal.amount = record.amount;
1025
+ if (record.note_ids !== undefined)
1026
+ proposal.noteIds = assertStringArray(requireArray(record, 'note_ids', context), 'note_ids', context);
1027
+ if (record.consume_notes_metadata_version !== undefined)
1028
+ proposal.consumeNotesMetadataVersion = requireNonNegativeInteger(record.consume_notes_metadata_version, 'consume_notes_metadata_version', context);
1029
+ if (record.consume_notes_notes !== undefined)
1030
+ proposal.consumeNotesNotes = assertStringArray(requireArray(record, 'consume_notes_notes', context), 'consume_notes_notes', context);
1031
+ if (record.target_threshold !== undefined)
1032
+ proposal.targetThreshold = requireNonNegativeInteger(record.target_threshold, 'target_threshold', context);
1033
+ if (record.signer_commitments !== undefined)
1034
+ proposal.signerCommitments = assertStringArray(requireArray(record, 'signer_commitments', context), 'signer_commitments', context);
1035
+ if (typeof record.new_guardian_pubkey === 'string')
1036
+ proposal.newGuardianPubkey = record.new_guardian_pubkey;
1037
+ if (typeof record.new_guardian_endpoint === 'string')
1038
+ proposal.newGuardianEndpoint = record.new_guardian_endpoint;
1039
+ if (typeof record.target_procedure === 'string')
1040
+ proposal.targetProcedure = record.target_procedure;
1041
+ return proposal;
1042
+ }
1043
+ function parseDeltaDetail(value) {
1044
+ const ctx = 'delta detail';
1045
+ const record = asRecord(value, ctx);
1046
+ const detail = {
1047
+ accountId: requireString(record, 'account_id', ctx),
1048
+ nonce: requireInteger(record, 'nonce', ctx),
1049
+ status: parseDeltaStatus(requireString(record, 'status', ctx), `${ctx}.status`),
1050
+ statusTimestamp: requireString(record, 'status_timestamp', ctx),
1051
+ prevCommitment: requireString(record, 'prev_commitment', ctx),
1052
+ newCommitment: requireNullableString(record, 'new_commitment', ctx),
1053
+ inputNotes: parseDecodedNoteArray(requireField(record, 'input_notes', ctx), `${ctx}.input_notes`),
1054
+ outputNotes: parseDecodedNoteArray(requireField(record, 'output_notes', ctx), `${ctx}.output_notes`),
1055
+ vaultChanges: parseVaultChangeArray(requireField(record, 'vault_changes', ctx), `${ctx}.vault_changes`),
1056
+ storageChanges: parseStorageChangeArray(requireField(record, 'storage_changes', ctx), `${ctx}.storage_changes`),
1057
+ };
1058
+ if (record.retry_count !== undefined) {
1059
+ const retry = record.retry_count;
1060
+ if (typeof retry !== 'number' || !Number.isInteger(retry) || retry < 0) {
1061
+ throw new GuardianOperatorContractError(ctx, 'retry_count must be a non-negative integer when present');
1062
+ }
1063
+ detail.retryCount = retry;
1064
+ }
1065
+ if (record.category !== undefined && record.category !== null) {
1066
+ detail.category = parseDeltaCategory(requireString(record, 'category', ctx), `${ctx}.category`);
1067
+ }
1068
+ if (record.proposal !== undefined && record.proposal !== null) {
1069
+ detail.proposal = parseDeltaProposalMetadata(record.proposal, `${ctx}.proposal`);
1070
+ }
1071
+ if (Array.isArray(record.decode_warnings) && record.decode_warnings.length > 0) {
1072
+ detail.decodeWarnings = record.decode_warnings.map((w, i) => parseDecodeWarning(w, `${ctx}.decode_warnings[${i}]`));
1073
+ }
1074
+ if (typeof record.raw_transaction_summary === 'string') {
1075
+ detail.rawTransactionSummary = record.raw_transaction_summary;
1076
+ }
1077
+ return detail;
1078
+ }
1079
+ function parseDecodedNoteArray(value, context) {
1080
+ if (!Array.isArray(value)) {
1081
+ throw new GuardianOperatorContractError(context, 'expected an array of decoded notes');
1082
+ }
1083
+ return value.map((entry, i) => parseDecodedNote(entry, `${context}[${i}]`));
1084
+ }
1085
+ const NOTE_TAG_VALUES = [
1086
+ 'p2id',
1087
+ 'p2ide',
1088
+ 'pswap',
1089
+ 'mint',
1090
+ 'burn',
1091
+ 'custom',
1092
+ ];
1093
+ function parseDecodedNote(value, context) {
1094
+ const record = asRecord(value, context);
1095
+ const tag = requireString(record, 'tag', context);
1096
+ if (!NOTE_TAG_VALUES.includes(tag)) {
1097
+ throw new GuardianOperatorContractError(context, `unknown note tag "${tag}"`);
1098
+ }
1099
+ const assets = requireField(record, 'assets', context);
1100
+ if (!Array.isArray(assets)) {
1101
+ throw new GuardianOperatorContractError(context, 'assets must be an array');
1102
+ }
1103
+ const note = {
1104
+ noteId: requireString(record, 'note_id', context),
1105
+ tag: tag,
1106
+ assets: assets.map((a, i) => parseDecodedAsset(a, `${context}.assets[${i}]`)),
1107
+ };
1108
+ if (typeof record.sender === 'string')
1109
+ note.sender = record.sender;
1110
+ if (typeof record.recipient === 'string')
1111
+ note.recipient = record.recipient;
1112
+ return note;
1113
+ }
1114
+ function parseDecodedAsset(value, context) {
1115
+ const record = asRecord(value, context);
1116
+ const kind = requireString(record, 'kind', context);
1117
+ if (kind !== 'fungible' && kind !== 'non_fungible') {
1118
+ throw new GuardianOperatorContractError(context, `asset kind must be "fungible" or "non_fungible", got "${kind}"`);
1119
+ }
1120
+ const asset = {
1121
+ assetId: requireString(record, 'asset_id', context),
1122
+ kind: kind,
1123
+ };
1124
+ if (typeof record.amount === 'string')
1125
+ asset.amount = record.amount;
1126
+ return asset;
1127
+ }
1128
+ function parseVaultChangeArray(value, context) {
1129
+ if (!Array.isArray(value)) {
1130
+ throw new GuardianOperatorContractError(context, 'expected an array of vault changes');
1131
+ }
1132
+ return value.map((entry, i) => parseVaultChange(entry, `${context}[${i}]`));
1133
+ }
1134
+ function parseVaultChange(value, context) {
1135
+ const record = asRecord(value, context);
1136
+ const kind = requireString(record, 'kind', context);
1137
+ if (kind === 'fungible') {
1138
+ return {
1139
+ kind: 'fungible',
1140
+ assetId: requireString(record, 'asset_id', context),
1141
+ change: requireString(record, 'change', context),
1142
+ };
1143
+ }
1144
+ if (kind === 'non_fungible') {
1145
+ const added = requireField(record, 'added', context);
1146
+ const removed = requireField(record, 'removed', context);
1147
+ if (!Array.isArray(added) || !Array.isArray(removed)) {
1148
+ throw new GuardianOperatorContractError(context, 'non_fungible added/removed must be arrays');
1149
+ }
1150
+ return {
1151
+ kind: 'non_fungible',
1152
+ assetId: requireString(record, 'asset_id', context),
1153
+ added: assertStringArray(added, 'added', context),
1154
+ removed: assertStringArray(removed, 'removed', context),
1155
+ };
1156
+ }
1157
+ throw new GuardianOperatorContractError(context, `vault change kind must be "fungible" or "non_fungible", got "${kind}"`);
1158
+ }
1159
+ function parseStorageChangeArray(value, context) {
1160
+ if (!Array.isArray(value)) {
1161
+ throw new GuardianOperatorContractError(context, 'expected an array of storage changes');
1162
+ }
1163
+ return value.map((entry, i) => parseStorageChange(entry, `${context}[${i}]`));
1164
+ }
1165
+ function parseStorageChange(value, context) {
1166
+ const record = asRecord(value, context);
1167
+ const change = {
1168
+ slotName: requireString(record, 'slot_name', context),
1169
+ after: requireNullableString(record, 'after', context),
1170
+ };
1171
+ if (record.key !== undefined && record.key !== null) {
1172
+ change.key = requireString(record, 'key', context);
1173
+ }
1174
+ if (record.before !== undefined) {
1175
+ change.before = requireNullableString(record, 'before', context);
1176
+ }
1177
+ return change;
1178
+ }
1179
+ const DECODE_SECTION_VALUES = [
1180
+ 'tx_summary',
1181
+ 'metadata',
1182
+ 'input_notes',
1183
+ 'output_notes',
1184
+ 'vault',
1185
+ 'storage',
1186
+ ];
1187
+ function parseDecodeWarning(value, context) {
1188
+ const record = asRecord(value, context);
1189
+ const section = requireString(record, 'section', context);
1190
+ if (!DECODE_SECTION_VALUES.includes(section)) {
1191
+ throw new GuardianOperatorContractError(context, `unknown decode section "${section}"`);
1192
+ }
1193
+ return {
1194
+ section: section,
1195
+ reason: requireString(record, 'reason', context),
1196
+ };
1197
+ }
1198
+ function parseDeltaStatus(value, context) {
1199
+ if (value === 'candidate' || value === 'canonical' || value === 'discarded') {
1200
+ return value;
1201
+ }
1202
+ throw new GuardianOperatorContractError(context, `expected status to be "candidate" / "canonical" / "discarded", got ${JSON.stringify(value)}`);
1203
+ }
306
1204
  //# sourceMappingURL=http.js.map