tapimo 0.0.1 → 0.0.2
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 +2 -1
- package/dist/admin/App.d.ts +123 -123
- package/dist/admin/ui.generated.d.ts +1 -1
- package/dist/admin/ui.generated.js +13 -13
- package/dist/admin/ui.generated.js.map +1 -1
- package/dist/apps/balances.d.ts +43 -43
- package/dist/apps/coingecko.d.ts +176 -176
- package/dist/apps/coingecko.js +1 -1
- package/dist/apps/coingecko.js.map +1 -1
- package/dist/apps/tokenlist.d.ts +36 -36
- package/dist/internal/Auth.js +6 -1
- package/dist/internal/Auth.js.map +1 -1
- package/dist/internal/Store.js +22 -5
- package/dist/internal/Store.js.map +1 -1
- package/dist/internal/Viem.d.ts +81 -7
- package/dist/internal/Viem.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/App.test.ts +1 -1
- package/src/admin/ui.generated.ts +13 -13
- package/src/apps/coingecko.ts +1 -1
- package/src/apps/indexer.test.ts +1 -1
- package/src/apps/webhooks.test.ts +1 -1
- package/src/internal/Auth.test.ts +59 -1
- package/src/internal/Auth.ts +6 -1
- package/src/internal/Store.test.ts +84 -0
- package/src/internal/Store.ts +24 -5
- package/src/internal/Viem.ts +2 -1
package/src/apps/coingecko.ts
CHANGED
package/src/apps/indexer.test.ts
CHANGED
|
@@ -117,7 +117,7 @@ describe('GET /indexer/query', () => {
|
|
|
117
117
|
getClient: () => Tempo.client,
|
|
118
118
|
recipient: Tempo.accounts[2].address,
|
|
119
119
|
},
|
|
120
|
-
secretKey: '
|
|
120
|
+
secretKey: 'secret_test_key_0123456789abcdef',
|
|
121
121
|
},
|
|
122
122
|
})
|
|
123
123
|
await exhaustQuota(RateLimit.memory({ store: rateLimitStore }), 'public:anonymous', {
|
|
@@ -1119,7 +1119,7 @@ describe('MPP access (disabled for now — API-key-only)', () => {
|
|
|
1119
1119
|
getClient: () => Tempo.client,
|
|
1120
1120
|
recipient: Tempo.accounts[2].address,
|
|
1121
1121
|
},
|
|
1122
|
-
secretKey: '
|
|
1122
|
+
secretKey: 'secret_test_key_0123456789abcdef',
|
|
1123
1123
|
},
|
|
1124
1124
|
webhook: { store: Store.memory() },
|
|
1125
1125
|
})
|
|
@@ -555,6 +555,64 @@ describe('require', () => {
|
|
|
555
555
|
}
|
|
556
556
|
`)
|
|
557
557
|
})
|
|
558
|
+
|
|
559
|
+
test('fails open when the public rate-limit store errors', async () => {
|
|
560
|
+
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
561
|
+
try {
|
|
562
|
+
const app = createApp({
|
|
563
|
+
auth: {
|
|
564
|
+
apiKey: { source: TestApp.source({ keys: [{ ...key, token }] }) },
|
|
565
|
+
rateLimit: {
|
|
566
|
+
async consume() {
|
|
567
|
+
throw new Error('Network connection lost.')
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
policy: { public: { rateLimit: { perMinute: 1 } } },
|
|
572
|
+
})
|
|
573
|
+
const response = await app.request('/protected', {
|
|
574
|
+
headers: { 'x-forwarded-for': '203.0.113.10' },
|
|
575
|
+
})
|
|
576
|
+
const body = await response.json()
|
|
577
|
+
|
|
578
|
+
expect(response.status).toMatchInlineSnapshot(`200`)
|
|
579
|
+
expect(response.headers.has('ratelimit-limit')).toMatchInlineSnapshot(`false`)
|
|
580
|
+
expect(body).toMatchInlineSnapshot(`
|
|
581
|
+
{
|
|
582
|
+
"id": "203.0.113.10",
|
|
583
|
+
"type": "public",
|
|
584
|
+
}
|
|
585
|
+
`)
|
|
586
|
+
expect(error.mock.calls.length).toMatchInlineSnapshot(`1`)
|
|
587
|
+
} finally {
|
|
588
|
+
error.mockRestore()
|
|
589
|
+
}
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
test('fails open when the API-key rate-limit store errors', async () => {
|
|
593
|
+
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
594
|
+
try {
|
|
595
|
+
const app = createApp({
|
|
596
|
+
auth: {
|
|
597
|
+
apiKey: { source: TestApp.source({ keys: [{ ...key, token }] }) },
|
|
598
|
+
rateLimit: {
|
|
599
|
+
async consume() {
|
|
600
|
+
throw new Error('Network connection lost.')
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
})
|
|
605
|
+
const response = await app.request('/protected', {
|
|
606
|
+
headers: { 'tempo-api-key': token },
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
expect(response.status).toMatchInlineSnapshot(`200`)
|
|
610
|
+
expect(response.headers.has('ratelimit-limit')).toMatchInlineSnapshot(`false`)
|
|
611
|
+
expect(error.mock.calls.length).toMatchInlineSnapshot(`1`)
|
|
612
|
+
} finally {
|
|
613
|
+
error.mockRestore()
|
|
614
|
+
}
|
|
615
|
+
})
|
|
558
616
|
})
|
|
559
617
|
|
|
560
618
|
describe('rateLimit per-scope', () => {
|
|
@@ -845,7 +903,7 @@ function createMppServer() {
|
|
|
845
903
|
}),
|
|
846
904
|
],
|
|
847
905
|
realm: 'tempo-api-test',
|
|
848
|
-
secretKey: '
|
|
906
|
+
secretKey: 'secret_test_key_0123456789abcdef',
|
|
849
907
|
})
|
|
850
908
|
}
|
|
851
909
|
|
package/src/internal/Auth.ts
CHANGED
|
@@ -796,7 +796,12 @@ function consumeRateLimit(
|
|
|
796
796
|
options: RateLimit.Store.ConsumeOptions,
|
|
797
797
|
): Promise<RateLimit.Result | null> {
|
|
798
798
|
if (!auth.rateLimit) return Promise.resolve(null)
|
|
799
|
-
|
|
799
|
+
// Fail open: a rate-limit store error (e.g. a lost Durable Object
|
|
800
|
+
// connection) degrades to unenforced quota instead of failing the request.
|
|
801
|
+
return auth.rateLimit.consume(options).catch((error) => {
|
|
802
|
+
console.error('[auth] rate-limit consume failed; failing open', error)
|
|
803
|
+
return null
|
|
804
|
+
})
|
|
800
805
|
}
|
|
801
806
|
|
|
802
807
|
/**
|
|
@@ -286,6 +286,90 @@ describe('durableObject', () => {
|
|
|
286
286
|
)
|
|
287
287
|
})
|
|
288
288
|
|
|
289
|
+
test('retries a retryable stub error once on a fresh stub', async () => {
|
|
290
|
+
let stubs = 0
|
|
291
|
+
let failures = 1
|
|
292
|
+
const backing = Store.memory()
|
|
293
|
+
const namespace = {
|
|
294
|
+
getByName() {
|
|
295
|
+
stubs += 1
|
|
296
|
+
return {
|
|
297
|
+
...backing,
|
|
298
|
+
increment: (key, options) => {
|
|
299
|
+
if (failures > 0) {
|
|
300
|
+
failures -= 1
|
|
301
|
+
throw Object.assign(new Error('Network connection lost.'), { retryable: true })
|
|
302
|
+
}
|
|
303
|
+
return Store.increment(backing, key, options)
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
} satisfies Store.durableObject.Namespace
|
|
308
|
+
const store = Store.durableObject(namespace, { name: 'tempo-api' })
|
|
309
|
+
|
|
310
|
+
expect(await Store.increment(store, 'counter')).toMatchInlineSnapshot(`1`)
|
|
311
|
+
// One stub per attempt: a stub that threw is disconnected.
|
|
312
|
+
expect(stubs).toMatchInlineSnapshot(`2`)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
test('propagates a retryable stub error when the retry also fails', async () => {
|
|
316
|
+
let attempts = 0
|
|
317
|
+
const namespace = {
|
|
318
|
+
getByName() {
|
|
319
|
+
return {
|
|
320
|
+
...Store.memory(),
|
|
321
|
+
increment: () => {
|
|
322
|
+
attempts += 1
|
|
323
|
+
throw Object.assign(new Error('Network connection lost.'), { retryable: true })
|
|
324
|
+
},
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
} satisfies Store.durableObject.Namespace
|
|
328
|
+
const store = Store.durableObject(namespace, { name: 'tempo-api' })
|
|
329
|
+
|
|
330
|
+
await expect(Store.increment(store, 'counter')).rejects.toThrowErrorMatchingInlineSnapshot(
|
|
331
|
+
`[Error: Network connection lost.]`,
|
|
332
|
+
)
|
|
333
|
+
expect(attempts).toMatchInlineSnapshot(`2`)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
test('does not retry non-retryable or overloaded stub errors', async () => {
|
|
337
|
+
let attempts = 0
|
|
338
|
+
const namespaceFor = (error: Error) =>
|
|
339
|
+
({
|
|
340
|
+
getByName() {
|
|
341
|
+
return {
|
|
342
|
+
...Store.memory(),
|
|
343
|
+
increment: () => {
|
|
344
|
+
attempts += 1
|
|
345
|
+
throw error
|
|
346
|
+
},
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
}) satisfies Store.durableObject.Namespace
|
|
350
|
+
|
|
351
|
+
const plain = Store.durableObject(namespaceFor(new Error('boom')), { name: 'tempo-api' })
|
|
352
|
+
await expect(Store.increment(plain, 'counter')).rejects.toThrowErrorMatchingInlineSnapshot(
|
|
353
|
+
`[Error: boom]`,
|
|
354
|
+
)
|
|
355
|
+
expect(attempts).toMatchInlineSnapshot(`1`)
|
|
356
|
+
|
|
357
|
+
attempts = 0
|
|
358
|
+
const overloaded = Store.durableObject(
|
|
359
|
+
namespaceFor(
|
|
360
|
+
Object.assign(new Error('Durable Object is overloaded.'), {
|
|
361
|
+
overloaded: true,
|
|
362
|
+
retryable: true,
|
|
363
|
+
}),
|
|
364
|
+
),
|
|
365
|
+
{ name: 'tempo-api' },
|
|
366
|
+
)
|
|
367
|
+
await expect(Store.increment(overloaded, 'counter')).rejects.toThrowErrorMatchingInlineSnapshot(
|
|
368
|
+
`[Error: Durable Object is overloaded.]`,
|
|
369
|
+
)
|
|
370
|
+
expect(attempts).toMatchInlineSnapshot(`1`)
|
|
371
|
+
})
|
|
372
|
+
|
|
289
373
|
test('defers missing namespace failures until an operation is attempted', async () => {
|
|
290
374
|
const store = Store.durableObject(undefined as unknown as Store.durableObject.Namespace, {
|
|
291
375
|
name: 'tempo-api',
|
package/src/internal/Store.ts
CHANGED
|
@@ -430,29 +430,48 @@ function durableObjectStub(
|
|
|
430
430
|
|
|
431
431
|
return from({
|
|
432
432
|
async delete(key) {
|
|
433
|
-
await stub(key).delete(key)
|
|
433
|
+
await durableObjectRetry(() => stub(key).delete(key))
|
|
434
434
|
},
|
|
435
435
|
async get(key) {
|
|
436
|
-
return stub(key).get(key)
|
|
436
|
+
return durableObjectRetry(() => stub(key).get(key))
|
|
437
437
|
},
|
|
438
438
|
// Native RPC increment: the read-modify-write runs inside the object
|
|
439
439
|
// (one round trip, atomic) instead of the get + put fallback (two).
|
|
440
440
|
async increment(key, options) {
|
|
441
|
-
return stub(key).increment(key, options)
|
|
441
|
+
return durableObjectRetry(() => stub(key).increment(key, options))
|
|
442
442
|
},
|
|
443
443
|
async list(options) {
|
|
444
444
|
// A sharded store scatters keys across objects, so no single object can
|
|
445
445
|
// enumerate them.
|
|
446
446
|
if (typeof name === 'function')
|
|
447
447
|
throw new TypeError('cannot list a sharded Durable Object store')
|
|
448
|
-
return stub('').list(options)
|
|
448
|
+
return durableObjectRetry(() => stub('').list(options))
|
|
449
449
|
},
|
|
450
450
|
async put(key, value, options) {
|
|
451
|
-
await stub(key).put(key, value, options)
|
|
451
|
+
await durableObjectRetry(() => stub(key).put(key, value, options))
|
|
452
452
|
},
|
|
453
453
|
})
|
|
454
454
|
}
|
|
455
455
|
|
|
456
|
+
/**
|
|
457
|
+
* Runs a Durable Object stub call, retrying once when workerd marks the error
|
|
458
|
+
* retryable (transient transport failures, e.g. "Network connection lost.")
|
|
459
|
+
* and not overloaded. Callers pass a thunk so the retry resolves a fresh
|
|
460
|
+
* stub: a stub that threw is disconnected.
|
|
461
|
+
*/
|
|
462
|
+
async function durableObjectRetry<value>(call: () => Promise<value>): Promise<value> {
|
|
463
|
+
try {
|
|
464
|
+
return await call()
|
|
465
|
+
} catch (error) {
|
|
466
|
+
const { overloaded, retryable } = (error ?? {}) as {
|
|
467
|
+
overloaded?: boolean | undefined
|
|
468
|
+
retryable?: boolean | undefined
|
|
469
|
+
}
|
|
470
|
+
if (retryable !== true || overloaded === true) throw error
|
|
471
|
+
return call()
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
456
475
|
function cloudflareKvExpirationTtl(ttl: number | undefined) {
|
|
457
476
|
if (ttl === undefined) return undefined
|
|
458
477
|
if (ttl <= 0) return 0
|
package/src/internal/Viem.ts
CHANGED
|
@@ -130,7 +130,8 @@ export declare namespace getClient {
|
|
|
130
130
|
Chain,
|
|
131
131
|
undefined,
|
|
132
132
|
undefined,
|
|
133
|
-
|
|
133
|
+
// Omit core's ERC-20 `token` namespace; `tempoActions` shadows it at runtime.
|
|
134
|
+
Omit<PublicActions<HttpTransport, Chain, undefined>, 'token'> & TempoActions<Chain, undefined>
|
|
134
135
|
>
|
|
135
136
|
|
|
136
137
|
/** Options for getting a Tempo viem client. */
|