@openzeppelin/guardian-client 0.15.0 → 0.16.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/README.md +16 -0
- package/dist/conversion.d.ts.map +1 -1
- package/dist/conversion.js +4 -2
- package/dist/conversion.js.map +1 -1
- package/dist/error-codes.d.ts +30 -0
- package/dist/error-codes.d.ts.map +1 -0
- package/dist/error-codes.js +81 -0
- package/dist/error-codes.js.map +1 -0
- package/dist/http.d.ts +70 -2
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +166 -3
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/server-types.d.ts +21 -0
- package/dist/server-types.d.ts.map +1 -1
- package/dist/types.d.ts +29 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +10 -1
- package/src/conversion.test.ts +16 -0
- package/src/conversion.ts +4 -2
- package/src/error-codes.test.ts +69 -0
- package/src/error-codes.ts +91 -0
- package/src/http.test.ts +301 -22
- package/src/http.ts +203 -3
- package/src/index.ts +10 -0
- package/src/server-types.ts +24 -1
- package/src/types.ts +32 -1
- package/dist/auth-request.test.d.ts +0 -2
- package/dist/auth-request.test.d.ts.map +0 -1
- package/dist/auth-request.test.js +0 -10
- package/dist/auth-request.test.js.map +0 -1
- package/dist/conversion.test.d.ts +0 -2
- package/dist/conversion.test.d.ts.map +0 -1
- package/dist/conversion.test.js +0 -332
- package/dist/conversion.test.js.map +0 -1
- package/dist/http.test.d.ts +0 -2
- package/dist/http.test.d.ts.map +0 -1
- package/dist/http.test.js +0 -672
- package/dist/http.test.js.map +0 -1
package/src/http.test.ts
CHANGED
|
@@ -69,6 +69,97 @@ describe('GuardianHttpClient', () => {
|
|
|
69
69
|
expect(error).toBeInstanceOf(GuardianHttpError);
|
|
70
70
|
expect(error.status).toBe(500);
|
|
71
71
|
expect(error.statusText).toBe('Internal Server Error');
|
|
72
|
+
// Non-JSON body: no typed envelope fields.
|
|
73
|
+
expect(error.code).toBeNull();
|
|
74
|
+
expect(error.releasedAt).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('exposes code and released_at from a GUARDIAN_ACCOUNT_RELEASED envelope', async () => {
|
|
78
|
+
mockFetch.mockResolvedValueOnce({
|
|
79
|
+
ok: false,
|
|
80
|
+
status: 409,
|
|
81
|
+
statusText: 'Conflict',
|
|
82
|
+
text: async () =>
|
|
83
|
+
JSON.stringify({
|
|
84
|
+
code: 'GUARDIAN_ACCOUNT_RELEASED',
|
|
85
|
+
message: 'This account has moved to a different guardian. Reconnect it to continue.',
|
|
86
|
+
meta: { retryable: false, released_at: '2026-07-06T10:00:00Z' },
|
|
87
|
+
}),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const error = await client.getPubkey().catch((e) => e);
|
|
91
|
+
expect(error).toBeInstanceOf(GuardianHttpError);
|
|
92
|
+
expect(error.status).toBe(409);
|
|
93
|
+
expect(error.code).toBe('account_released');
|
|
94
|
+
expect(error.rawCode).toBe('GUARDIAN_ACCOUNT_RELEASED');
|
|
95
|
+
expect(error.releasedAt).toBe('2026-07-06T10:00:00Z');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('exposes code without releasedAt for other envelope errors', async () => {
|
|
99
|
+
mockFetch.mockResolvedValueOnce({
|
|
100
|
+
ok: false,
|
|
101
|
+
status: 404,
|
|
102
|
+
statusText: 'Not Found',
|
|
103
|
+
text: async () =>
|
|
104
|
+
JSON.stringify({
|
|
105
|
+
code: 'account_not_found',
|
|
106
|
+
message: "We couldn't find that. It may have been completed or removed.",
|
|
107
|
+
meta: { retryable: false },
|
|
108
|
+
}),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const error = await client.getPubkey().catch((e) => e);
|
|
112
|
+
expect(error.code).toBe('account_not_found');
|
|
113
|
+
expect(error.releasedAt).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('getStatus', () => {
|
|
118
|
+
it('maps the server status response to camelCase', async () => {
|
|
119
|
+
mockFetch.mockResolvedValueOnce({
|
|
120
|
+
ok: true,
|
|
121
|
+
json: async () => ({
|
|
122
|
+
status: 'ok',
|
|
123
|
+
version: '0.1.0',
|
|
124
|
+
git_commit: 'abc123def456',
|
|
125
|
+
environment: 'devnet',
|
|
126
|
+
started_at: '2026-06-17T10:00:00Z',
|
|
127
|
+
uptime_seconds: 3600,
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const status = await client.getStatus();
|
|
132
|
+
|
|
133
|
+
expect(status).toEqual({
|
|
134
|
+
status: 'ok',
|
|
135
|
+
version: '0.1.0',
|
|
136
|
+
gitCommit: 'abc123def456',
|
|
137
|
+
environment: 'devnet',
|
|
138
|
+
startedAt: '2026-06-17T10:00:00Z',
|
|
139
|
+
uptimeSeconds: 3600,
|
|
140
|
+
});
|
|
141
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
142
|
+
'http://localhost:3000/status',
|
|
143
|
+
expect.objectContaining({
|
|
144
|
+
method: 'GET',
|
|
145
|
+
headers: expect.objectContaining({
|
|
146
|
+
'Content-Type': 'application/json',
|
|
147
|
+
}),
|
|
148
|
+
})
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should throw GuardianHttpError on non-ok response', async () => {
|
|
153
|
+
mockFetch.mockResolvedValueOnce({
|
|
154
|
+
ok: false,
|
|
155
|
+
status: 503,
|
|
156
|
+
statusText: 'Service Unavailable',
|
|
157
|
+
text: async () => 'down',
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const error = await client.getStatus().catch((e) => e);
|
|
161
|
+
expect(error).toBeInstanceOf(GuardianHttpError);
|
|
162
|
+
expect(error.status).toBe(503);
|
|
72
163
|
});
|
|
73
164
|
});
|
|
74
165
|
|
|
@@ -365,6 +456,157 @@ describe('GuardianHttpClient', () => {
|
|
|
365
456
|
});
|
|
366
457
|
});
|
|
367
458
|
|
|
459
|
+
describe('abandonCandidate', () => {
|
|
460
|
+
it('should record an abandon intent and map the response to camelCase', async () => {
|
|
461
|
+
client.setSigner(mockSigner);
|
|
462
|
+
|
|
463
|
+
mockFetch.mockResolvedValueOnce({
|
|
464
|
+
ok: true,
|
|
465
|
+
json: async () => ({
|
|
466
|
+
account_id: '0x' + 'a'.repeat(30),
|
|
467
|
+
nonce: 7,
|
|
468
|
+
state: 'pending',
|
|
469
|
+
abandon_requested_at: '2026-07-14T12:00:00Z',
|
|
470
|
+
}),
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
const result = await client.abandonCandidate('0x' + 'a'.repeat(30), 7);
|
|
474
|
+
|
|
475
|
+
expect(result).toEqual({
|
|
476
|
+
accountId: '0x' + 'a'.repeat(30),
|
|
477
|
+
nonce: 7,
|
|
478
|
+
state: 'pending',
|
|
479
|
+
abandonRequestedAt: '2026-07-14T12:00:00Z',
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// Wire format is snake_case
|
|
483
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
484
|
+
'http://localhost:3000/delta/candidate/abandon',
|
|
485
|
+
expect.objectContaining({
|
|
486
|
+
method: 'POST',
|
|
487
|
+
body: JSON.stringify({
|
|
488
|
+
account_id: '0x' + 'a'.repeat(30),
|
|
489
|
+
nonce: 7,
|
|
490
|
+
}),
|
|
491
|
+
})
|
|
492
|
+
);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('surfaces 409 GUARDIAN_CANDIDATE_LANDED with a parseable error envelope', async () => {
|
|
496
|
+
client.setSigner(mockSigner);
|
|
497
|
+
|
|
498
|
+
mockFetch.mockResolvedValueOnce({
|
|
499
|
+
ok: false,
|
|
500
|
+
status: 409,
|
|
501
|
+
statusText: 'Conflict',
|
|
502
|
+
text: async () =>
|
|
503
|
+
JSON.stringify({
|
|
504
|
+
code: 'GUARDIAN_CANDIDATE_LANDED',
|
|
505
|
+
message: "This transaction already went through, so it can't be abandoned.",
|
|
506
|
+
meta: { retryable: false },
|
|
507
|
+
}),
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const error = await client
|
|
511
|
+
.abandonCandidate('0x' + 'a'.repeat(30), 7)
|
|
512
|
+
.catch((e) => e);
|
|
513
|
+
expect(error).toBeInstanceOf(GuardianHttpError);
|
|
514
|
+
expect(error.status).toBe(409);
|
|
515
|
+
expect(error.code).toBe('candidate_landed');
|
|
516
|
+
expect(error.rawCode).toBe('GUARDIAN_CANDIDATE_LANDED');
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('surfaces 404 delta_not_found when no candidate exists at the nonce', async () => {
|
|
520
|
+
client.setSigner(mockSigner);
|
|
521
|
+
|
|
522
|
+
mockFetch.mockResolvedValueOnce({
|
|
523
|
+
ok: false,
|
|
524
|
+
status: 404,
|
|
525
|
+
statusText: 'Not Found',
|
|
526
|
+
text: async () =>
|
|
527
|
+
JSON.stringify({
|
|
528
|
+
code: 'delta_not_found',
|
|
529
|
+
message: "We couldn't find that. It may have been completed or removed.",
|
|
530
|
+
meta: { retryable: false },
|
|
531
|
+
}),
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
const error = await client
|
|
535
|
+
.abandonCandidate('0x' + 'a'.repeat(30), 7)
|
|
536
|
+
.catch((e) => e);
|
|
537
|
+
expect(error).toBeInstanceOf(GuardianHttpError);
|
|
538
|
+
expect(error.status).toBe(404);
|
|
539
|
+
expect(error.code).toBe('delta_not_found');
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
describe('abandonStatus', () => {
|
|
544
|
+
const serverDelta = (status: object) => ({
|
|
545
|
+
account_id: '0x' + 'a'.repeat(30),
|
|
546
|
+
nonce: 7,
|
|
547
|
+
prev_commitment: '0x' + 'b'.repeat(64),
|
|
548
|
+
delta_payload: { tx_summary: { data: 'base64summary' }, signatures: [] },
|
|
549
|
+
status,
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it('classifies a still-pending candidate as waiting', async () => {
|
|
553
|
+
client.setSigner(mockSigner);
|
|
554
|
+
mockFetch.mockResolvedValueOnce({
|
|
555
|
+
ok: true,
|
|
556
|
+
json: async () =>
|
|
557
|
+
serverDelta({ status: 'candidate', timestamp: '2026-07-14T12:00:00Z' }),
|
|
558
|
+
});
|
|
559
|
+
expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7)).toBe('waiting');
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it('classifies a canonicalized delta as landed', async () => {
|
|
563
|
+
client.setSigner(mockSigner);
|
|
564
|
+
mockFetch.mockResolvedValueOnce({
|
|
565
|
+
ok: true,
|
|
566
|
+
json: async () =>
|
|
567
|
+
serverDelta({ status: 'canonical', timestamp: '2026-07-14T12:00:00Z' }),
|
|
568
|
+
});
|
|
569
|
+
expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7)).toBe('landed');
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('classifies a client-abandoned discard as abandoned', async () => {
|
|
573
|
+
client.setSigner(mockSigner);
|
|
574
|
+
mockFetch.mockResolvedValueOnce({
|
|
575
|
+
ok: true,
|
|
576
|
+
json: async () =>
|
|
577
|
+
serverDelta({
|
|
578
|
+
status: 'discarded',
|
|
579
|
+
timestamp: '2026-07-14T12:00:00Z',
|
|
580
|
+
reason: 'client_abandoned',
|
|
581
|
+
}),
|
|
582
|
+
});
|
|
583
|
+
expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7)).toBe('abandoned');
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('classifies a reasonless discard and a missing delta as unexpected', async () => {
|
|
587
|
+
client.setSigner(mockSigner);
|
|
588
|
+
mockFetch.mockResolvedValueOnce({
|
|
589
|
+
ok: true,
|
|
590
|
+
json: async () =>
|
|
591
|
+
serverDelta({ status: 'discarded', timestamp: '2026-07-14T12:00:00Z' }),
|
|
592
|
+
});
|
|
593
|
+
expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7)).toBe('unexpected');
|
|
594
|
+
|
|
595
|
+
mockFetch.mockResolvedValueOnce({
|
|
596
|
+
ok: false,
|
|
597
|
+
status: 404,
|
|
598
|
+
statusText: 'Not Found',
|
|
599
|
+
text: async () =>
|
|
600
|
+
JSON.stringify({
|
|
601
|
+
code: 'delta_not_found',
|
|
602
|
+
message: "We couldn't find that. It may have been completed or removed.",
|
|
603
|
+
meta: { retryable: false },
|
|
604
|
+
}),
|
|
605
|
+
});
|
|
606
|
+
expect(await client.abandonStatus('0x' + 'a'.repeat(30), 7)).toBe('unexpected');
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
|
|
368
610
|
describe('signDeltaProposal', () => {
|
|
369
611
|
it('should sign a delta proposal', async () => {
|
|
370
612
|
client.setSigner(mockSigner);
|
|
@@ -733,6 +975,27 @@ describe('GuardianHttpError', () => {
|
|
|
733
975
|
expect(error.name).toBe('GuardianHttpError');
|
|
734
976
|
});
|
|
735
977
|
|
|
978
|
+
it('parses a { code, message, meta } body into structured accessors (feature 009)', () => {
|
|
979
|
+
const body = JSON.stringify({
|
|
980
|
+
code: 'rate_limit_exceeded',
|
|
981
|
+
message: 'Too many requests — please try again shortly.',
|
|
982
|
+
meta: { retryable: true, retry_after_secs: 30 },
|
|
983
|
+
});
|
|
984
|
+
const error = new GuardianHttpError(429, 'Too Many Requests', body);
|
|
985
|
+
expect(error.code).toBe('rate_limit_exceeded');
|
|
986
|
+
expect(error.userMessage).toBe('Too many requests — please try again shortly.');
|
|
987
|
+
expect(error.meta?.retryable).toBe(true);
|
|
988
|
+
expect(error.meta?.retryAfterSecs).toBe(30); // snake_case → camelCase
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
it('leaves accessors undefined for a non-JSON / non-conforming body', () => {
|
|
992
|
+
const plain = new GuardianHttpError(502, 'Bad Gateway', 'upstream exploded');
|
|
993
|
+
expect(plain.code).toBeNull();
|
|
994
|
+
expect(plain.releasedAt).toBeNull();
|
|
995
|
+
expect(plain.userMessage).toBeUndefined();
|
|
996
|
+
expect(plain.meta).toBeUndefined();
|
|
997
|
+
});
|
|
998
|
+
|
|
736
999
|
describe('error envelope contract (account-paused path)', () => {
|
|
737
1000
|
let client: GuardianHttpClient;
|
|
738
1001
|
beforeEach(() => {
|
|
@@ -743,15 +1006,18 @@ describe('GuardianHttpError', () => {
|
|
|
743
1006
|
it('surfaces 409 GUARDIAN_ACCOUNT_PAUSED with a parseable error envelope on pushDeltaProposal', async () => {
|
|
744
1007
|
client.setSigner(mockSigner);
|
|
745
1008
|
|
|
746
|
-
// The server's GuardianError::AccountPaused → IntoResponse contract
|
|
747
|
-
//
|
|
1009
|
+
// The server's GuardianError::AccountPaused → IntoResponse contract,
|
|
1010
|
+
// reshaped to { code, message, meta } (feature 009). Locks client/server
|
|
1011
|
+
// in lockstep: a regression to the legacy { success, error, paused_* } or
|
|
748
1012
|
// "(400, {delta: {account_id: 'error text'}})" shape would break this.
|
|
749
1013
|
const envelope = {
|
|
750
|
-
success: false,
|
|
751
1014
|
code: 'GUARDIAN_ACCOUNT_PAUSED',
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
1015
|
+
message: "This account is paused and can't approve transactions right now.",
|
|
1016
|
+
meta: {
|
|
1017
|
+
retryable: false,
|
|
1018
|
+
paused_at: '2026-05-20T10:00:00Z',
|
|
1019
|
+
paused_reason: 'compliance review',
|
|
1020
|
+
},
|
|
755
1021
|
};
|
|
756
1022
|
|
|
757
1023
|
mockFetch.mockResolvedValueOnce({
|
|
@@ -772,26 +1038,37 @@ describe('GuardianHttpError', () => {
|
|
|
772
1038
|
.catch((e) => e as GuardianHttpError);
|
|
773
1039
|
|
|
774
1040
|
expect(error).toBeInstanceOf(GuardianHttpError);
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
expect(
|
|
780
|
-
expect(
|
|
781
|
-
expect(
|
|
782
|
-
expect(
|
|
783
|
-
|
|
1041
|
+
const e = error as GuardianHttpError;
|
|
1042
|
+
expect(e.status).toBe(409);
|
|
1043
|
+
|
|
1044
|
+
// Structured accessors parsed from { code, message, meta } (feature 009).
|
|
1045
|
+
expect(e.code).toBe('account_paused');
|
|
1046
|
+
expect(e.rawCode).toBe('GUARDIAN_ACCOUNT_PAUSED');
|
|
1047
|
+
expect(typeof e.userMessage).toBe('string');
|
|
1048
|
+
expect(e.userMessage).not.toContain('compliance review'); // sanitized
|
|
1049
|
+
expect(e.meta?.retryable).toBe(false);
|
|
1050
|
+
expect(e.meta?.pausedAt).toBe('2026-05-20T10:00:00Z');
|
|
1051
|
+
expect(e.meta?.pausedReason).toBe('compliance review');
|
|
1052
|
+
|
|
1053
|
+
const parsed = JSON.parse(e.body);
|
|
1054
|
+
// Legacy fields gone; not a domain object.
|
|
1055
|
+
expect(parsed.success).toBeUndefined();
|
|
1056
|
+
expect(parsed.error).toBeUndefined();
|
|
784
1057
|
expect(parsed.delta).toBeUndefined();
|
|
785
1058
|
});
|
|
786
1059
|
|
|
787
1060
|
it('surfaces 401 AUTHENTICATION_FAILED with a parseable error envelope', async () => {
|
|
788
1061
|
client.setSigner(mockSigner);
|
|
789
1062
|
const envelope = {
|
|
790
|
-
success: false,
|
|
791
1063
|
code: 'authentication_failed',
|
|
792
|
-
|
|
1064
|
+
message: 'Your session has expired. Please sign in again.',
|
|
1065
|
+
meta: { retryable: false },
|
|
793
1066
|
};
|
|
794
|
-
|
|
1067
|
+
// `authentication_failed` triggers the replay-retry path (the specific
|
|
1068
|
+
// "Replay attack" detail is sanitized off the wire in feature 009, so we
|
|
1069
|
+
// retry on the auth code). Use a persistent mock so the retries resolve
|
|
1070
|
+
// and the final attempt throws the typed error.
|
|
1071
|
+
mockFetch.mockResolvedValue({
|
|
795
1072
|
ok: false,
|
|
796
1073
|
status: 401,
|
|
797
1074
|
statusText: 'Unauthorized',
|
|
@@ -807,10 +1084,12 @@ describe('GuardianHttpError', () => {
|
|
|
807
1084
|
.catch((e) => e as GuardianHttpError);
|
|
808
1085
|
|
|
809
1086
|
expect(error).toBeInstanceOf(GuardianHttpError);
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
expect(
|
|
813
|
-
expect(
|
|
1087
|
+
const e = error as GuardianHttpError;
|
|
1088
|
+
expect(e.status).toBe(401);
|
|
1089
|
+
expect(e.code).toBe('authentication_failed');
|
|
1090
|
+
expect(typeof e.userMessage).toBe('string');
|
|
1091
|
+
const parsed = JSON.parse(e.body);
|
|
1092
|
+
expect(parsed.success).toBeUndefined();
|
|
814
1093
|
expect(parsed.delta).toBeUndefined();
|
|
815
1094
|
});
|
|
816
1095
|
});
|
package/src/http.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type GuardianErrorCode,
|
|
3
|
+
normalizeGuardianErrorCode,
|
|
4
|
+
} from './error-codes.js';
|
|
1
5
|
import type {
|
|
6
|
+
AbandonCandidateResponse,
|
|
7
|
+
AbandonStatus,
|
|
2
8
|
ConfigureRequest,
|
|
3
9
|
ConfigureResponse,
|
|
4
10
|
DeltaObject,
|
|
@@ -12,9 +18,12 @@ import type {
|
|
|
12
18
|
SignatureScheme,
|
|
13
19
|
Signer,
|
|
14
20
|
StateObject,
|
|
21
|
+
StatusResponse,
|
|
15
22
|
} from './types.js';
|
|
16
23
|
import { RequestAuthPayload } from './auth-request.js';
|
|
17
24
|
import type {
|
|
25
|
+
ServerAbandonCandidateRequest,
|
|
26
|
+
ServerAbandonCandidateResponse,
|
|
18
27
|
ServerDeltaObject,
|
|
19
28
|
ServerDeltaProposalResponse,
|
|
20
29
|
ServerLookupResponse,
|
|
@@ -23,6 +32,7 @@ import type {
|
|
|
23
32
|
ServerStateObject,
|
|
24
33
|
ServerConfigureResponse,
|
|
25
34
|
ServerPushDeltaResponse,
|
|
35
|
+
ServerStatusResponse,
|
|
26
36
|
} from './server-types.js';
|
|
27
37
|
import {
|
|
28
38
|
fromServerConfigureResponse,
|
|
@@ -36,16 +46,125 @@ import {
|
|
|
36
46
|
} from './conversion.js';
|
|
37
47
|
|
|
38
48
|
/**
|
|
39
|
-
*
|
|
49
|
+
* Structured machine-readable side-data on a GUARDIAN error
|
|
50
|
+
* (feature `009-human-readable-errors`). `retryable` is always present.
|
|
51
|
+
*/
|
|
52
|
+
export interface GuardianErrorMeta {
|
|
53
|
+
retryable: boolean;
|
|
54
|
+
retryAfterSecs?: number;
|
|
55
|
+
missingPermissions?: string[];
|
|
56
|
+
pausedAt?: string;
|
|
57
|
+
pausedReason?: string | null;
|
|
58
|
+
releasedAt?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface ParsedGuardianError {
|
|
62
|
+
/** Typed code, or `null` when the wire code is outside the known vocabulary. */
|
|
63
|
+
code: GuardianErrorCode | null;
|
|
64
|
+
/** Verbatim wire code, kept even when it does not narrow to the union. */
|
|
65
|
+
rawCode: string;
|
|
66
|
+
message: string;
|
|
67
|
+
meta: GuardianErrorMeta;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Parse a GUARDIAN error body `{ code, message, meta }` (feature 009),
|
|
72
|
+
* mapping the server's snake_case `meta` fields to camelCase. Returns
|
|
73
|
+
* `undefined` for non-JSON or non-conforming bodies — including a missing
|
|
74
|
+
* `meta` or a non-boolean `meta.retryable`, which the contract requires;
|
|
75
|
+
* treating those as conforming would silently misclassify retryability for
|
|
76
|
+
* bodies from older servers or intermediary proxies.
|
|
77
|
+
*/
|
|
78
|
+
function parseGuardianErrorBody(body: string): ParsedGuardianError | undefined {
|
|
79
|
+
let json: unknown;
|
|
80
|
+
try {
|
|
81
|
+
json = JSON.parse(body);
|
|
82
|
+
} catch {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
if (typeof json !== 'object' || json === null) return undefined;
|
|
86
|
+
const obj = json as Record<string, unknown>;
|
|
87
|
+
if (typeof obj.code !== 'string' || typeof obj.message !== 'string') return undefined;
|
|
88
|
+
|
|
89
|
+
if (typeof obj.meta !== 'object' || obj.meta === null || Array.isArray(obj.meta)) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
const rawMeta = obj.meta as Record<string, unknown>;
|
|
93
|
+
if (typeof rawMeta.retryable !== 'boolean') return undefined;
|
|
94
|
+
const code = normalizeGuardianErrorCode(obj.code);
|
|
95
|
+
const meta: GuardianErrorMeta = { retryable: rawMeta.retryable };
|
|
96
|
+
if (
|
|
97
|
+
typeof rawMeta.retry_after_secs === 'number' &&
|
|
98
|
+
Number.isInteger(rawMeta.retry_after_secs) &&
|
|
99
|
+
rawMeta.retry_after_secs >= 0
|
|
100
|
+
) {
|
|
101
|
+
meta.retryAfterSecs = rawMeta.retry_after_secs;
|
|
102
|
+
}
|
|
103
|
+
if (Array.isArray(rawMeta.missing_permissions)) {
|
|
104
|
+
meta.missingPermissions = rawMeta.missing_permissions.filter(
|
|
105
|
+
(x): x is string => typeof x === 'string'
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (typeof rawMeta.paused_at === 'string') meta.pausedAt = rawMeta.paused_at;
|
|
109
|
+
if (typeof rawMeta.released_at === 'string') meta.releasedAt = rawMeta.released_at;
|
|
110
|
+
if (typeof rawMeta.paused_reason === 'string' || rawMeta.paused_reason === null) {
|
|
111
|
+
meta.pausedReason = rawMeta.paused_reason as string | null;
|
|
112
|
+
}
|
|
113
|
+
return { code, rawCode: obj.code, message: obj.message, meta };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Error thrown by the GUARDIAN HTTP client. Parses the `{ code, message, meta }`
|
|
118
|
+
* error body (feature 009): branch on {@link code}, display {@link userMessage}.
|
|
40
119
|
*/
|
|
41
120
|
export class GuardianHttpError extends Error {
|
|
121
|
+
/**
|
|
122
|
+
* Typed, compiler-checked Guardian error code (issue #318), normalized to
|
|
123
|
+
* snake_case (e.g. `account_paused`, `account_released`,
|
|
124
|
+
* `commitment_mismatch`). `null` when the body is not a conforming JSON
|
|
125
|
+
* envelope OR the server emitted a code outside this client's known
|
|
126
|
+
* vocabulary — in the latter case {@link rawCode} still carries the
|
|
127
|
+
* verbatim wire string. Branch on this rather than on `body` text or the
|
|
128
|
+
* HTTP status alone; comparing against a non-member literal is a type
|
|
129
|
+
* error, so typos are caught at compile time.
|
|
130
|
+
*/
|
|
131
|
+
public readonly code: GuardianErrorCode | null;
|
|
132
|
+
/**
|
|
133
|
+
* Verbatim wire code as the server sent it (e.g.
|
|
134
|
+
* `GUARDIAN_ACCOUNT_PAUSED`), including codes a newer server may emit
|
|
135
|
+
* that this client does not know. `null` only when the body carried no
|
|
136
|
+
* conforming envelope. For logging/telemetry; branch on {@link code}.
|
|
137
|
+
*/
|
|
138
|
+
public readonly rawCode: string | null;
|
|
139
|
+
/** Short, user-safe message — safe to display verbatim in a wallet UI. */
|
|
140
|
+
readonly userMessage?: string;
|
|
141
|
+
/** Structured side-data (`retryable`, `retryAfterSecs`, …). */
|
|
142
|
+
readonly meta?: GuardianErrorMeta;
|
|
143
|
+
/**
|
|
144
|
+
* RFC 3339 UTC timestamp at which the guardian released the account
|
|
145
|
+
* after it switched to a different guardian. Convenience accessor for
|
|
146
|
+
* `meta.releasedAt`; present only when `code === 'account_released'`
|
|
147
|
+
* (wire form `GUARDIAN_ACCOUNT_RELEASED`, HTTP 409); the account is
|
|
148
|
+
* terminal on this server until re-onboarded via `configure`.
|
|
149
|
+
*/
|
|
150
|
+
public readonly releasedAt: string | null;
|
|
151
|
+
|
|
42
152
|
constructor(
|
|
43
153
|
public readonly status: number,
|
|
44
154
|
public readonly statusText: string,
|
|
45
155
|
public readonly body: string
|
|
46
156
|
) {
|
|
47
|
-
|
|
157
|
+
// Only the parsed, user-safe message is folded into Error.message; the
|
|
158
|
+
// raw body (which may carry backend/proxy internals) stays on the `body`
|
|
159
|
+
// field for diagnostics only.
|
|
160
|
+
const parsed = parseGuardianErrorBody(body);
|
|
161
|
+
super(`GUARDIAN HTTP error ${status}: ${statusText}${parsed ? ` - ${parsed.message}` : ''}`);
|
|
48
162
|
this.name = 'GuardianHttpError';
|
|
163
|
+
this.code = parsed?.code ?? null;
|
|
164
|
+
this.rawCode = parsed?.rawCode ?? null;
|
|
165
|
+
this.userMessage = parsed?.message;
|
|
166
|
+
this.meta = parsed?.meta;
|
|
167
|
+
this.releasedAt = parsed?.meta.releasedAt ?? null;
|
|
49
168
|
}
|
|
50
169
|
}
|
|
51
170
|
|
|
@@ -87,6 +206,19 @@ export class GuardianHttpClient {
|
|
|
87
206
|
};
|
|
88
207
|
}
|
|
89
208
|
|
|
209
|
+
async getStatus(): Promise<StatusResponse> {
|
|
210
|
+
const response = await this.fetch('/status', { method: 'GET' });
|
|
211
|
+
const data = (await response.json()) as ServerStatusResponse;
|
|
212
|
+
return {
|
|
213
|
+
status: data.status,
|
|
214
|
+
version: data.version,
|
|
215
|
+
gitCommit: data.git_commit,
|
|
216
|
+
environment: data.environment,
|
|
217
|
+
startedAt: data.started_at,
|
|
218
|
+
uptimeSeconds: data.uptime_seconds,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
90
222
|
async configure(request: ConfigureRequest): Promise<ConfigureResponse> {
|
|
91
223
|
const serverRequest = toServerConfigureRequest(request);
|
|
92
224
|
const response = await this.fetchAuthenticated('/configure', {
|
|
@@ -157,6 +289,65 @@ export class GuardianHttpClient {
|
|
|
157
289
|
};
|
|
158
290
|
}
|
|
159
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Request abandonment of a pending canonicalization candidate whose
|
|
294
|
+
* transaction will never land on-chain (issue #319).
|
|
295
|
+
*
|
|
296
|
+
* Records an abandon *intent* (`202 Accepted`): the delta stays a
|
|
297
|
+
* candidate — the account stays locked — until the guardian's worker
|
|
298
|
+
* confirms over the abandon quarantine that the transaction did not
|
|
299
|
+
* land, then discards the delta as `client_abandoned` and releases the
|
|
300
|
+
* account (typically well under a minute). Poll {@link abandonStatus}
|
|
301
|
+
* for the resolution. Refused with `GUARDIAN_CANDIDATE_LANDED` (409)
|
|
302
|
+
* when the transaction actually landed. Retries are idempotent and
|
|
303
|
+
* preserve the original request timestamp.
|
|
304
|
+
*/
|
|
305
|
+
async abandonCandidate(accountId: string, nonce: number): Promise<AbandonCandidateResponse> {
|
|
306
|
+
const serverRequest: ServerAbandonCandidateRequest = { account_id: accountId, nonce };
|
|
307
|
+
const response = await this.fetchAuthenticated('/delta/candidate/abandon', {
|
|
308
|
+
method: 'POST',
|
|
309
|
+
body: JSON.stringify(serverRequest),
|
|
310
|
+
}, accountId, serverRequest);
|
|
311
|
+
const server = (await response.json()) as ServerAbandonCandidateResponse;
|
|
312
|
+
return {
|
|
313
|
+
accountId: server.account_id,
|
|
314
|
+
nonce: server.nonce,
|
|
315
|
+
state: server.state,
|
|
316
|
+
abandonRequestedAt: server.abandon_requested_at,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Poll the resolution of an abandon request made with
|
|
322
|
+
* {@link abandonCandidate}: `'waiting'` while the quarantine runs,
|
|
323
|
+
* `'landed'` if the transaction landed after all (the delta
|
|
324
|
+
* canonicalized), `'abandoned'` once the delta is discarded as
|
|
325
|
+
* client-abandoned and the account released, `'unexpected'` for any
|
|
326
|
+
* state no abandon flow produces (including a missing delta).
|
|
327
|
+
*/
|
|
328
|
+
async abandonStatus(accountId: string, nonce: number): Promise<AbandonStatus> {
|
|
329
|
+
let delta: DeltaObject;
|
|
330
|
+
try {
|
|
331
|
+
delta = await this.getDelta(accountId, nonce);
|
|
332
|
+
} catch (e) {
|
|
333
|
+
if (e instanceof GuardianHttpError && e.code === 'delta_not_found') {
|
|
334
|
+
return 'unexpected';
|
|
335
|
+
}
|
|
336
|
+
throw e;
|
|
337
|
+
}
|
|
338
|
+
switch (delta.status.status) {
|
|
339
|
+
case 'candidate':
|
|
340
|
+
return 'waiting';
|
|
341
|
+
case 'canonical':
|
|
342
|
+
return 'landed';
|
|
343
|
+
case 'discarded':
|
|
344
|
+
return delta.status.reason === 'client_abandoned' ? 'abandoned' : 'unexpected';
|
|
345
|
+
default:
|
|
346
|
+
return 'unexpected';
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
|
|
160
351
|
async signDeltaProposal(request: SignProposalRequest): Promise<DeltaObject> {
|
|
161
352
|
const serverRequest = toServerSignProposalRequest(request);
|
|
162
353
|
const response = await this.fetchAuthenticated('/delta/proposal', {
|
|
@@ -303,7 +494,16 @@ export class GuardianHttpClient {
|
|
|
303
494
|
},
|
|
304
495
|
});
|
|
305
496
|
} catch (err) {
|
|
306
|
-
|
|
497
|
+
// Replay rejections (stale timestamp) are transient: retry once with a
|
|
498
|
+
// fresh timestamp. The specific "Replay attack" detail is now sanitized
|
|
499
|
+
// off the wire (feature 009), so branch on the stable auth code instead.
|
|
500
|
+
// Retrying a genuine auth failure is harmless — one extra attempt that
|
|
501
|
+
// also fails — and only happens when a retry budget remains.
|
|
502
|
+
if (
|
|
503
|
+
retries > 0 &&
|
|
504
|
+
err instanceof GuardianHttpError &&
|
|
505
|
+
err.code === 'authentication_failed'
|
|
506
|
+
) {
|
|
307
507
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
308
508
|
return this.fetchAuthenticated(path, init, accountId, requestPayload, retries - 1);
|
|
309
509
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
export { GuardianHttpClient, GuardianHttpError } from './http.js';
|
|
2
|
+
export type { GuardianErrorMeta } from './http.js';
|
|
3
|
+
export {
|
|
4
|
+
GUARDIAN_ERROR_CODES,
|
|
5
|
+
isGuardianErrorCode,
|
|
6
|
+
normalizeGuardianErrorCode,
|
|
7
|
+
} from './error-codes.js';
|
|
8
|
+
export type { GuardianErrorCode } from './error-codes.js';
|
|
2
9
|
export { RequestAuthPayload } from './auth-request.js';
|
|
3
10
|
|
|
4
11
|
export type {
|
|
12
|
+
AbandonCandidateResponse,
|
|
13
|
+
AbandonStatus,
|
|
5
14
|
Signer,
|
|
6
15
|
FalconSignature,
|
|
7
16
|
EcdsaSignature,
|
|
@@ -18,6 +27,7 @@ export type {
|
|
|
18
27
|
ConfigureRequest,
|
|
19
28
|
ConfigureResponse,
|
|
20
29
|
PubkeyResponse,
|
|
30
|
+
StatusResponse,
|
|
21
31
|
DeltaProposalRequest,
|
|
22
32
|
DeltaProposalResponse,
|
|
23
33
|
ProposalsResponse,
|