@oxy.so/core 1.6.1 → 1.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/LICENSE +675 -201
- package/NOTICE +7 -2
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +146 -23
- package/dist/cjs/crypto/registrationPow.js +53 -0
- package/dist/cjs/mixins/OxyServices.auth.js +6 -0
- package/dist/cjs/mixins/OxyServices.user.js +8 -1
- package/dist/cjs/utils/asyncUtils.js +50 -9
- package/dist/cjs/utils/errorUtils.js +53 -3
- package/dist/cjs/utils/requestUtils.js +96 -14
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +147 -24
- package/dist/esm/crypto/registrationPow.js +50 -0
- package/dist/esm/mixins/OxyServices.auth.js +8 -2
- package/dist/esm/mixins/OxyServices.identityBackup.js +1 -1
- package/dist/esm/mixins/OxyServices.user.js +8 -1
- package/dist/esm/utils/asyncUtils.js +50 -9
- package/dist/esm/utils/errorUtils.js +51 -3
- package/dist/esm/utils/requestUtils.js +96 -14
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +19 -0
- package/dist/types/crypto/registrationPow.d.ts +22 -0
- package/dist/types/mixins/OxyServices.user.d.ts +10 -2
- package/dist/types/utils/asyncUtils.d.ts +30 -1
- package/dist/types/utils/errorUtils.d.ts +36 -0
- package/dist/types/utils/requestUtils.d.ts +2 -2
- package/package.json +2 -2
- package/src/HttpService.ts +181 -31
- package/src/__tests__/fixEsmImports.test.ts +132 -0
- package/src/__tests__/httpServiceAbort.test.ts +308 -0
- package/src/crypto/__tests__/registrationPow.test.ts +61 -0
- package/src/crypto/registrationPow.ts +60 -0
- package/src/mixins/OxyServices.auth.ts +7 -0
- package/src/mixins/OxyServices.user.ts +12 -1
- package/src/utils/__tests__/asyncUtils.test.ts +93 -1
- package/src/utils/__tests__/requestUtilsAbort.test.ts +155 -0
- package/src/utils/asyncUtils.ts +97 -9
- package/src/utils/errorUtils.ts +78 -3
- package/src/utils/requestUtils.ts +104 -13
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cancellation and timeout regressions.
|
|
3
|
+
*
|
|
4
|
+
* `HttpService` used to build its `AbortController` INSIDE the function
|
|
5
|
+
* `retryAsync` re-invokes, and link the caller's signal to each new controller
|
|
6
|
+
* with a bare `addEventListener`. Since `abort` is a once-only event, a signal
|
|
7
|
+
* that had already fired could not abort the controller built for attempt 2 —
|
|
8
|
+
* so a request the caller had CANCELLED was re-issued for real. A caller abort
|
|
9
|
+
* also reached the retry predicate as an `AbortError` carrying `status: 0`,
|
|
10
|
+
* which is not 4xx, so the predicate read it as a transient failure worth
|
|
11
|
+
* another try. With the 5s default timeout and 3 retries that is ~28s of work
|
|
12
|
+
* after the caller stopped waiting, inside a request queue ten slots deep.
|
|
13
|
+
*
|
|
14
|
+
* Every test here pins one half of "a cancellation is an instruction, not a
|
|
15
|
+
* failure": nothing retries it, nothing queues it, and nothing leaks after it.
|
|
16
|
+
*/
|
|
17
|
+
import { HttpService } from '../HttpService';
|
|
18
|
+
import { ErrorCodes } from '../utils/errorUtils';
|
|
19
|
+
|
|
20
|
+
function jsonResponse(data: unknown, status = 200): Response {
|
|
21
|
+
return new Response(JSON.stringify({ data }), {
|
|
22
|
+
status,
|
|
23
|
+
headers: { 'content-type': 'application/json' },
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A fetch that never responds, but DOES honour its abort signal — rejecting
|
|
29
|
+
* with an `AbortError` exactly as the real one does.
|
|
30
|
+
*
|
|
31
|
+
* Honouring the signal is not harness politeness, it is the whole point: the
|
|
32
|
+
* behaviour under test is what happens after an abort, so a fake that ignores
|
|
33
|
+
* the signal would hang instead of exercising it.
|
|
34
|
+
*/
|
|
35
|
+
function hangingFetch(): { fetch: typeof globalThis.fetch; calls: () => number } {
|
|
36
|
+
let calls = 0;
|
|
37
|
+
return {
|
|
38
|
+
fetch: ((_input: RequestInfo | URL, init?: RequestInit) => {
|
|
39
|
+
calls++;
|
|
40
|
+
return new Promise<Response>((_resolve, reject) => {
|
|
41
|
+
const signal = init?.signal;
|
|
42
|
+
const fail = (): void => reject(new DOMException('The operation was aborted.', 'AbortError'));
|
|
43
|
+
if (!signal) return;
|
|
44
|
+
if (signal.aborted) { fail(); return; }
|
|
45
|
+
signal.addEventListener('abort', fail, { once: true });
|
|
46
|
+
});
|
|
47
|
+
}) as typeof globalThis.fetch,
|
|
48
|
+
calls: () => calls,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pin the retry backoff's jitter to zero.
|
|
54
|
+
*
|
|
55
|
+
* `retryAsync`'s delay is `baseDelay * 2**attempt + Math.random() * 1000`, so
|
|
56
|
+
* with real timers a retry can land up to a second later. A test that waits a
|
|
57
|
+
* fixed 80ms to prove "no retry happened" would then pass whether the bug was
|
|
58
|
+
* present or not — it would just be measuring the jitter. Pinning it makes the
|
|
59
|
+
* retry window exactly `baseDelay * (1 + 2 + 4)`, which a short wait covers
|
|
60
|
+
* decisively.
|
|
61
|
+
*/
|
|
62
|
+
function pinJitter(): void {
|
|
63
|
+
jest.spyOn(Math, 'random').mockReturnValue(0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('HttpService cancellation', () => {
|
|
67
|
+
const originalFetch = globalThis.fetch;
|
|
68
|
+
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
globalThis.fetch = originalFetch;
|
|
71
|
+
jest.restoreAllMocks();
|
|
72
|
+
jest.useRealTimers();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('never calls fetch for a signal that was already aborted', async () => {
|
|
76
|
+
let calls = 0;
|
|
77
|
+
globalThis.fetch = (async () => { calls++; return jsonResponse({}); }) as typeof globalThis.fetch;
|
|
78
|
+
|
|
79
|
+
const http = new HttpService({ baseURL: 'https://api.test' });
|
|
80
|
+
const controller = new AbortController();
|
|
81
|
+
controller.abort();
|
|
82
|
+
|
|
83
|
+
await expect(http.get('/thing', { signal: controller.signal })).rejects.toMatchObject({
|
|
84
|
+
code: ErrorCodes.CANCELLED,
|
|
85
|
+
});
|
|
86
|
+
expect(calls).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('issues exactly one fetch when a request is cancelled mid-flight, with retry on', async () => {
|
|
90
|
+
pinJitter();
|
|
91
|
+
const { fetch, calls } = hangingFetch();
|
|
92
|
+
globalThis.fetch = fetch;
|
|
93
|
+
|
|
94
|
+
const http = new HttpService({
|
|
95
|
+
baseURL: 'https://api.test',
|
|
96
|
+
enableRetry: true,
|
|
97
|
+
maxRetries: 3,
|
|
98
|
+
retryDelay: 1,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const controller = new AbortController();
|
|
102
|
+
const pending = http.get('/thing', { signal: controller.signal });
|
|
103
|
+
const assertion = expect(pending).rejects.toMatchObject({ code: ErrorCodes.CANCELLED });
|
|
104
|
+
|
|
105
|
+
// Let the request reach fetch, then cancel it.
|
|
106
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
107
|
+
expect(calls()).toBe(1);
|
|
108
|
+
controller.abort();
|
|
109
|
+
await assertion;
|
|
110
|
+
|
|
111
|
+
// The retry budget was 3 more attempts, whose backoff with jitter pinned is
|
|
112
|
+
// 1+2+4ms. Waiting well past that window is what proves none of them ran,
|
|
113
|
+
// and it is the crux of the defect: the re-issued request went out AFTER
|
|
114
|
+
// the rejection the caller saw, so a test that stopped at the rejection
|
|
115
|
+
// would have passed with the bug fully present.
|
|
116
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
117
|
+
expect(calls()).toBe(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('leaves no abort listener on a caller signal that outlives the request', async () => {
|
|
121
|
+
globalThis.fetch = (async () => jsonResponse({ ok: true })) as typeof globalThis.fetch;
|
|
122
|
+
|
|
123
|
+
const controller = new AbortController();
|
|
124
|
+
const added: unknown[] = [];
|
|
125
|
+
const removed: unknown[] = [];
|
|
126
|
+
const realAdd = controller.signal.addEventListener.bind(controller.signal);
|
|
127
|
+
const realRemove = controller.signal.removeEventListener.bind(controller.signal);
|
|
128
|
+
jest.spyOn(controller.signal, 'addEventListener').mockImplementation(((type: string, fn: never, opts: never) => {
|
|
129
|
+
if (type === 'abort') added.push(fn);
|
|
130
|
+
return realAdd(type as 'abort', fn, opts);
|
|
131
|
+
}) as never);
|
|
132
|
+
jest.spyOn(controller.signal, 'removeEventListener').mockImplementation(((type: string, fn: never, opts: never) => {
|
|
133
|
+
if (type === 'abort') removed.push(fn);
|
|
134
|
+
return realRemove(type as 'abort', fn, opts);
|
|
135
|
+
}) as never);
|
|
136
|
+
|
|
137
|
+
const http = new HttpService({ baseURL: 'https://api.test' });
|
|
138
|
+
// A React Query query signal is reused across the query's whole lifetime,
|
|
139
|
+
// so a listener left behind per request is an unbounded leak, not a nit.
|
|
140
|
+
for (let i = 0; i < 5; i++) {
|
|
141
|
+
await http.get(`/thing-${i}`, { signal: controller.signal, cache: false, deduplicate: false });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
expect(added.length).toBeGreaterThan(0);
|
|
145
|
+
expect(removed.length).toBe(added.length);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('does not consume a queue slot for a cancelled request, so a live one still runs', async () => {
|
|
149
|
+
const live: string[] = [];
|
|
150
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
151
|
+
const url = String(input);
|
|
152
|
+
if (url.includes('/live')) {
|
|
153
|
+
live.push(url);
|
|
154
|
+
return jsonResponse({ ok: true });
|
|
155
|
+
}
|
|
156
|
+
// Cancelled requests never get here; if one does, it would hold a slot.
|
|
157
|
+
return new Promise<Response>(() => { /* never settles */ });
|
|
158
|
+
}) as typeof globalThis.fetch;
|
|
159
|
+
|
|
160
|
+
const http = new HttpService({
|
|
161
|
+
baseURL: 'https://api.test',
|
|
162
|
+
maxConcurrentRequests: 2,
|
|
163
|
+
enableRetry: false,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Fill more than the pool with requests whose callers have already gone.
|
|
167
|
+
const dead = Array.from({ length: 6 }, (_, i) => {
|
|
168
|
+
const controller = new AbortController();
|
|
169
|
+
controller.abort();
|
|
170
|
+
return http.get(`/dead-${i}`, { signal: controller.signal }).catch(() => 'cancelled');
|
|
171
|
+
});
|
|
172
|
+
await Promise.all(dead);
|
|
173
|
+
|
|
174
|
+
// With only two slots, this is the assertion that matters: the live request
|
|
175
|
+
// is not queued behind six abandoned ones.
|
|
176
|
+
await expect(http.get('/live', { cache: false })).resolves.toBeDefined();
|
|
177
|
+
expect(live).toHaveLength(1);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('HttpService timeout', () => {
|
|
182
|
+
const originalFetch = globalThis.fetch;
|
|
183
|
+
|
|
184
|
+
afterEach(() => {
|
|
185
|
+
globalThis.fetch = originalFetch;
|
|
186
|
+
jest.restoreAllMocks();
|
|
187
|
+
jest.useRealTimers();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('does not retry a timeout by default, and reports it as a timeout not a cancellation', async () => {
|
|
191
|
+
pinJitter();
|
|
192
|
+
const { fetch, calls } = hangingFetch();
|
|
193
|
+
globalThis.fetch = fetch;
|
|
194
|
+
|
|
195
|
+
const http = new HttpService({
|
|
196
|
+
baseURL: 'https://api.test',
|
|
197
|
+
enableRetry: true,
|
|
198
|
+
maxRetries: 3,
|
|
199
|
+
retryDelay: 1,
|
|
200
|
+
requestTimeout: 20,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await expect(http.get('/slow', { cache: false })).rejects.toMatchObject({
|
|
204
|
+
code: ErrorCodes.TIMEOUT,
|
|
205
|
+
timeout: true,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
209
|
+
// One attempt, not four. Four behind a short timeout is how a single slow
|
|
210
|
+
// endpoint used to cost tens of seconds of wall clock.
|
|
211
|
+
expect(calls()).toBe(1);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('retries a timeout when the caller opts in', async () => {
|
|
215
|
+
const { fetch, calls } = hangingFetch();
|
|
216
|
+
globalThis.fetch = fetch;
|
|
217
|
+
|
|
218
|
+
const http = new HttpService({
|
|
219
|
+
baseURL: 'https://api.test',
|
|
220
|
+
enableRetry: true,
|
|
221
|
+
maxRetries: 2,
|
|
222
|
+
retryDelay: 1,
|
|
223
|
+
requestTimeout: 20,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
await expect(
|
|
227
|
+
http.get('/slow', { cache: false, retryOnTimeout: true }),
|
|
228
|
+
).rejects.toMatchObject({ code: ErrorCodes.TIMEOUT });
|
|
229
|
+
|
|
230
|
+
expect(calls()).toBe(3);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('bounds total wall clock with a deadline, even with retries on', async () => {
|
|
234
|
+
pinJitter();
|
|
235
|
+
const { fetch } = hangingFetch();
|
|
236
|
+
globalThis.fetch = fetch;
|
|
237
|
+
|
|
238
|
+
const http = new HttpService({
|
|
239
|
+
baseURL: 'https://api.test',
|
|
240
|
+
enableRetry: true,
|
|
241
|
+
maxRetries: 5,
|
|
242
|
+
retryDelay: 100,
|
|
243
|
+
requestTimeout: 20,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const started = Date.now();
|
|
247
|
+
await expect(
|
|
248
|
+
http.get('/slow', { cache: false, retryOnTimeout: true, deadline: 150 }),
|
|
249
|
+
).rejects.toBeDefined();
|
|
250
|
+
|
|
251
|
+
// `timeout` bounds an attempt; `deadline` bounds the call. Without it the
|
|
252
|
+
// total is attempts x timeout + backoff, which no call site computes.
|
|
253
|
+
expect(Date.now() - started).toBeLessThan(400);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('still retries a 5xx — the fix must not disable retry wholesale', async () => {
|
|
257
|
+
let calls = 0;
|
|
258
|
+
globalThis.fetch = (async () => {
|
|
259
|
+
calls++;
|
|
260
|
+
return jsonResponse({ error: 'boom' }, 503);
|
|
261
|
+
}) as typeof globalThis.fetch;
|
|
262
|
+
|
|
263
|
+
const http = new HttpService({
|
|
264
|
+
baseURL: 'https://api.test',
|
|
265
|
+
enableRetry: true,
|
|
266
|
+
maxRetries: 2,
|
|
267
|
+
retryDelay: 1,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
await expect(http.get('/flaky', { cache: false })).rejects.toBeDefined();
|
|
271
|
+
expect(calls).toBe(3);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('still refuses to retry a 4xx', async () => {
|
|
275
|
+
let calls = 0;
|
|
276
|
+
globalThis.fetch = (async () => {
|
|
277
|
+
calls++;
|
|
278
|
+
return jsonResponse({ error: 'nope' }, 404);
|
|
279
|
+
}) as typeof globalThis.fetch;
|
|
280
|
+
|
|
281
|
+
const http = new HttpService({
|
|
282
|
+
baseURL: 'https://api.test',
|
|
283
|
+
enableRetry: true,
|
|
284
|
+
maxRetries: 3,
|
|
285
|
+
retryDelay: 1,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
await expect(http.get('/missing', { cache: false })).rejects.toBeDefined();
|
|
289
|
+
expect(calls).toBe(1);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('arms no timer past a request that threw before returning', async () => {
|
|
293
|
+
globalThis.fetch = (async () => jsonResponse({ error: 'nope' }, 404)) as typeof globalThis.fetch;
|
|
294
|
+
jest.useFakeTimers({ doNotFake: ['nextTick'] });
|
|
295
|
+
|
|
296
|
+
const http = new HttpService({
|
|
297
|
+
baseURL: 'https://api.test',
|
|
298
|
+
enableRetry: false,
|
|
299
|
+
requestTimeout: 30_000,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
await expect(http.get('/missing', { cache: false })).rejects.toBeDefined();
|
|
303
|
+
// `clearTimeout` used to sit on the success path only, so any throw between
|
|
304
|
+
// the fetch and the return left a 30s timer armed.
|
|
305
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
306
|
+
jest.useRealTimers();
|
|
307
|
+
});
|
|
308
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client half of registration proof-of-work: `solveRegistrationPow` must
|
|
3
|
+
* find a nonce that an INDEPENDENT hasher (not the one it used to search)
|
|
4
|
+
* agrees clears the shared difficulty — otherwise the client and the server
|
|
5
|
+
* (`packages/api`, which hashes with Node's `crypto`, not `@noble/hashes`)
|
|
6
|
+
* could silently disagree about what "solved" means.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import crypto from 'crypto';
|
|
10
|
+
import {
|
|
11
|
+
meetsRegistrationPowDifficulty,
|
|
12
|
+
registrationPowMessage,
|
|
13
|
+
REGISTRATION_POW_DIFFICULTY_BITS,
|
|
14
|
+
} from '@oxy.so/protocol';
|
|
15
|
+
import { solveRegistrationPow } from '../registrationPow';
|
|
16
|
+
|
|
17
|
+
function nodeDigestOf(publicKey: string, timestamp: number, nonce: string): string {
|
|
18
|
+
return crypto
|
|
19
|
+
.createHash('sha256')
|
|
20
|
+
.update(registrationPowMessage(publicKey, timestamp, nonce))
|
|
21
|
+
.digest('hex');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('solveRegistrationPow', () => {
|
|
25
|
+
it('finds a nonce that Node crypto (not @noble/hashes) also agrees clears the difficulty', async () => {
|
|
26
|
+
const publicKey = 'test-public-key';
|
|
27
|
+
const timestamp = 1_700_000_000_000;
|
|
28
|
+
|
|
29
|
+
const nonce = await solveRegistrationPow(publicKey, timestamp);
|
|
30
|
+
const digest = nodeDigestOf(publicKey, timestamp, nonce);
|
|
31
|
+
|
|
32
|
+
expect(meetsRegistrationPowDifficulty(digest, REGISTRATION_POW_DIFFICULTY_BITS)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('is deterministic: the same publicKey/timestamp always yields the same nonce', async () => {
|
|
36
|
+
const publicKey = 'deterministic-key';
|
|
37
|
+
const timestamp = 1_700_000_000_001;
|
|
38
|
+
|
|
39
|
+
const first = await solveRegistrationPow(publicKey, timestamp);
|
|
40
|
+
const second = await solveRegistrationPow(publicKey, timestamp);
|
|
41
|
+
|
|
42
|
+
expect(second).toBe(first);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('binds the nonce to publicKey and timestamp, not just to itself', async () => {
|
|
46
|
+
const timestamp = 1_700_000_000_002;
|
|
47
|
+
const nonce = await solveRegistrationPow('bound-key', timestamp);
|
|
48
|
+
|
|
49
|
+
// The nonce solves its OWN message under the shared difficulty check...
|
|
50
|
+
expect(
|
|
51
|
+
meetsRegistrationPowDifficulty(nodeDigestOf('bound-key', timestamp, nonce), REGISTRATION_POW_DIFFICULTY_BITS)
|
|
52
|
+
).toBe(true);
|
|
53
|
+
// ...but the message it was solved for is bound to that exact publicKey —
|
|
54
|
+
// reusing the nonce string under an unrelated one hashes to something
|
|
55
|
+
// unrelated, i.e. `registrationPowMessage` actually mixes `publicKey` in
|
|
56
|
+
// rather than the digest depending on `nonce` alone.
|
|
57
|
+
expect(registrationPowMessage('bound-key', timestamp, nonce)).not.toBe(
|
|
58
|
+
registrationPowMessage('a-different-key', timestamp, nonce)
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registration proof-of-work — the CLIENT half of the anti-automation nonce
|
|
3
|
+
* `register()` (`mixins/OxyServices.auth.ts`) submits alongside the
|
|
4
|
+
* registration signature. The message format, difficulty and the full design
|
|
5
|
+
* rationale (why 16 bits, why sequential nonces are fine, what this is and
|
|
6
|
+
* isn't a defense against) live in `@oxy.so/protocol`'s
|
|
7
|
+
* `auth/registrationPow.ts` — this file is only the solve loop.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import './polyfill';
|
|
11
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
12
|
+
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils';
|
|
13
|
+
import {
|
|
14
|
+
meetsRegistrationPowDifficulty,
|
|
15
|
+
registrationPowMessage,
|
|
16
|
+
REGISTRATION_POW_DIFFICULTY_BITS,
|
|
17
|
+
} from '@oxy.so/protocol';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* How many attempts the grind loop runs before yielding to the event loop.
|
|
21
|
+
*
|
|
22
|
+
* A synchronous hot loop long enough to matter (an average solve at 16 bits
|
|
23
|
+
* is 65,536 attempts) would otherwise block the JS thread for its entire
|
|
24
|
+
* duration — on a mobile app that means no touch response, no animation, for
|
|
25
|
+
* up to roughly a second. Yielding periodically keeps the UI responsive
|
|
26
|
+
* during the grind at the cost of a handful of event-loop round trips, which
|
|
27
|
+
* is imperceptible next to the grind itself.
|
|
28
|
+
*/
|
|
29
|
+
const YIELD_EVERY_N_ATTEMPTS = 2000;
|
|
30
|
+
|
|
31
|
+
function yieldToEventLoop(): Promise<void> {
|
|
32
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Grind a nonce for `publicKey`/`timestamp` that clears
|
|
37
|
+
* {@link REGISTRATION_POW_DIFFICULTY_BITS}, returned as a decimal counter.
|
|
38
|
+
*
|
|
39
|
+
* Hashes with `@noble/hashes/sha256` — pure JS, synchronous, identical output
|
|
40
|
+
* on web/Node/React Native with no native module and no per-attempt bridge
|
|
41
|
+
* crossing (see the difficulty constant's own doc for why that distinction is
|
|
42
|
+
* the whole point). Nonces are sequential (0, 1, 2, …) rather than random: the
|
|
43
|
+
* candidate space a hash function spreads solutions across is dense either
|
|
44
|
+
* way, so a counter finds one exactly as reliably as randomness would, without
|
|
45
|
+
* needing a randomness source at all.
|
|
46
|
+
*/
|
|
47
|
+
export async function solveRegistrationPow(publicKey: string, timestamp: number): Promise<string> {
|
|
48
|
+
for (let nonce = 0; ; nonce += 1) {
|
|
49
|
+
const candidate = String(nonce);
|
|
50
|
+
const digest = bytesToHex(
|
|
51
|
+
sha256(utf8ToBytes(registrationPowMessage(publicKey, timestamp, candidate)))
|
|
52
|
+
);
|
|
53
|
+
if (meetsRegistrationPowDifficulty(digest, REGISTRATION_POW_DIFFICULTY_BITS)) {
|
|
54
|
+
return candidate;
|
|
55
|
+
}
|
|
56
|
+
if (nonce % YIELD_EVERY_N_ATTEMPTS === YIELD_EVERY_N_ATTEMPTS - 1) {
|
|
57
|
+
await yieldToEventLoop();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -19,6 +19,7 @@ export {
|
|
|
19
19
|
} from '../utils/commonsApproval';
|
|
20
20
|
import { OxyAuthenticationError } from '../OxyServices.errors';
|
|
21
21
|
import { KeyManager } from '../crypto/keyManager';
|
|
22
|
+
import { solveRegistrationPow } from '../crypto/registrationPow';
|
|
22
23
|
import { SignatureService } from '../crypto/signatureService';
|
|
23
24
|
import { loadNodeCrypto } from '@oxy.so/protocol';
|
|
24
25
|
import { logger } from '../logger';
|
|
@@ -844,10 +845,16 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
844
845
|
timestamp: number
|
|
845
846
|
): Promise<{ message: string; user: User }> {
|
|
846
847
|
try {
|
|
848
|
+
// Advisory for now (server soft-enforces — see
|
|
849
|
+
// `SessionController.register`), but solved unconditionally so every
|
|
850
|
+
// client is already sending it once the server starts requiring it.
|
|
851
|
+
const powNonce = await solveRegistrationPow(publicKey, timestamp);
|
|
852
|
+
|
|
847
853
|
const res = await this.makeRequest<{ message: string; user: User }>('POST', '/auth/register', {
|
|
848
854
|
publicKey,
|
|
849
855
|
signature,
|
|
850
856
|
timestamp,
|
|
857
|
+
powNonce,
|
|
851
858
|
}, { cache: false, skipAuth: true });
|
|
852
859
|
|
|
853
860
|
if (!res || (typeof res === 'object' && Object.keys(res).length === 0)) {
|
|
@@ -202,8 +202,18 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
202
202
|
|
|
203
203
|
/**
|
|
204
204
|
* Search user profiles
|
|
205
|
+
*
|
|
206
|
+
* Takes an `AbortSignal` because people search is typed into: without one
|
|
207
|
+
* every keystroke started a search that ran to completion and had its
|
|
208
|
+
* result discarded, while holding one of the request queue's slots. It was
|
|
209
|
+
* the only search lane in Mention that could not be cancelled, purely
|
|
210
|
+
* because this signature had no way to say so.
|
|
205
211
|
*/
|
|
206
|
-
async searchProfiles(
|
|
212
|
+
async searchProfiles(
|
|
213
|
+
query: string,
|
|
214
|
+
pagination?: PaginationParams,
|
|
215
|
+
options?: { signal?: AbortSignal }
|
|
216
|
+
): Promise<SearchProfilesResponse> {
|
|
207
217
|
try {
|
|
208
218
|
const response = await this.makeRequest<SearchProfilesResponse>(
|
|
209
219
|
'GET',
|
|
@@ -212,6 +222,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
212
222
|
{
|
|
213
223
|
cache: true,
|
|
214
224
|
cacheTTL: 2 * 60 * 1000, // 2 minutes cache
|
|
225
|
+
signal: options?.signal,
|
|
215
226
|
}
|
|
216
227
|
);
|
|
217
228
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { retryAsync } from '../asyncUtils';
|
|
2
|
-
import { handleHttpError } from '../errorUtils';
|
|
2
|
+
import { createCancelledError, ErrorCodes, handleHttpError } from '../errorUtils';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Regression coverage for the 1.11.11 retry storm:
|
|
@@ -185,3 +185,95 @@ describe('handleHttpError preserves HTTP status for retry predicates', () => {
|
|
|
185
185
|
expect(result.code).toBe('INTERNAL_ERROR');
|
|
186
186
|
});
|
|
187
187
|
});
|
|
188
|
+
|
|
189
|
+
describe('retryAsync refuses to retry a cancellation', () => {
|
|
190
|
+
it('stops after one attempt on a cancellation, whatever the retry budget', async () => {
|
|
191
|
+
const operation = jest.fn(async () => { throw createCancelledError(); });
|
|
192
|
+
|
|
193
|
+
await expect(retryAsync(operation, { maxRetries: 3, baseDelay: 1 })).rejects.toMatchObject({
|
|
194
|
+
code: ErrorCodes.CANCELLED,
|
|
195
|
+
});
|
|
196
|
+
// This is the defect in one assertion: a cancellation carries no HTTP
|
|
197
|
+
// status, `status: 0` is not 4xx, so the status-only predicate passed it
|
|
198
|
+
// into the retry loop and the SDK re-issued abandoned requests.
|
|
199
|
+
expect(operation).toHaveBeenCalledTimes(1);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('refuses a cancellation even when a custom shouldRetry says yes', async () => {
|
|
203
|
+
const operation = jest.fn(async () => { throw createCancelledError(); });
|
|
204
|
+
|
|
205
|
+
await expect(
|
|
206
|
+
retryAsync(operation, { maxRetries: 3, baseDelay: 1, shouldRetry: () => true }),
|
|
207
|
+
).rejects.toBeDefined();
|
|
208
|
+
// A custom predicate decides which FAILURES deserve another attempt. It has
|
|
209
|
+
// no business overriding an explicit instruction to stop.
|
|
210
|
+
expect(operation).toHaveBeenCalledTimes(1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('treats a bare AbortError as a cancellation', async () => {
|
|
214
|
+
const operation = jest.fn(async () => {
|
|
215
|
+
throw new DOMException('The operation was aborted.', 'AbortError');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
await expect(retryAsync(operation, { maxRetries: 3, baseDelay: 1 })).rejects.toBeDefined();
|
|
219
|
+
// An unclassified abort is assumed to be a cancellation: refusing to retry
|
|
220
|
+
// something abandoned costs nothing, retrying it is the bug.
|
|
221
|
+
expect(operation).toHaveBeenCalledTimes(1);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe('retryAsync timeout policy', () => {
|
|
226
|
+
afterEach(() => { jest.restoreAllMocks(); });
|
|
227
|
+
|
|
228
|
+
const timeoutError = (): Error => {
|
|
229
|
+
const error = new Error('timed out') as Error & { code?: string; timeout?: boolean; status?: number };
|
|
230
|
+
error.code = ErrorCodes.TIMEOUT;
|
|
231
|
+
error.timeout = true;
|
|
232
|
+
error.status = 0;
|
|
233
|
+
return error;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
it('does not retry a timeout by default', async () => {
|
|
237
|
+
const operation = jest.fn(async () => { throw timeoutError(); });
|
|
238
|
+
|
|
239
|
+
await expect(retryAsync(operation, { maxRetries: 3, baseDelay: 1 })).rejects.toBeDefined();
|
|
240
|
+
expect(operation).toHaveBeenCalledTimes(1);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('retries a timeout when asked to', async () => {
|
|
244
|
+
const operation = jest.fn(async () => { throw timeoutError(); });
|
|
245
|
+
|
|
246
|
+
await expect(
|
|
247
|
+
retryAsync(operation, { maxRetries: 2, baseDelay: 1, retryOnTimeout: true }),
|
|
248
|
+
).rejects.toBeDefined();
|
|
249
|
+
expect(operation).toHaveBeenCalledTimes(3);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('stops at the deadline rather than at the attempt budget', async () => {
|
|
253
|
+
// Jitter pinned so the backoff is exactly baseDelay * 2**attempt; otherwise
|
|
254
|
+
// this measures Math.random() rather than the deadline.
|
|
255
|
+
jest.spyOn(Math, 'random').mockReturnValue(0);
|
|
256
|
+
const operation = jest.fn(async () => { throw new Error('5xx-ish'); });
|
|
257
|
+
|
|
258
|
+
const started = Date.now();
|
|
259
|
+
await expect(
|
|
260
|
+
retryAsync(operation, { maxRetries: 10, baseDelay: 50, deadline: Date.now() + 120 }),
|
|
261
|
+
).rejects.toBeDefined();
|
|
262
|
+
|
|
263
|
+
// The point of a deadline: the wall clock a caller feels is bounded by a
|
|
264
|
+
// budget it stated, not by arithmetic over attempts x timeout + backoff.
|
|
265
|
+
expect(Date.now() - started).toBeLessThan(400);
|
|
266
|
+
expect(operation.mock.calls.length).toBeLessThan(11);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe('retryAsync keeps its positional signature working', () => {
|
|
271
|
+
it('accepts the legacy (operation, maxRetries, baseDelay, shouldRetry) call', async () => {
|
|
272
|
+
const operation = jest.fn(async () => { throw new Error('transient'); });
|
|
273
|
+
|
|
274
|
+
await expect(retryAsync(operation, 2, 1)).rejects.toThrow('transient');
|
|
275
|
+
// In-tree callers pass positionally, so the options object had to be an
|
|
276
|
+
// overload rather than a replacement.
|
|
277
|
+
expect(operation).toHaveBeenCalledTimes(3);
|
|
278
|
+
});
|
|
279
|
+
});
|