tapimo 0.4.3 → 0.5.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/dist/App.d.ts +7222 -6316
- package/dist/App.d.ts.map +1 -1
- package/dist/App.js +4 -0
- package/dist/App.js.map +1 -1
- package/dist/Scope.d.ts +18 -1
- package/dist/Scope.d.ts.map +1 -1
- package/dist/Scope.js +24 -3
- package/dist/Scope.js.map +1 -1
- package/dist/admin/apps/verified-tokens.d.ts +116 -116
- package/dist/apps/data/App.d.ts +2541 -2607
- package/dist/apps/data/App.d.ts.map +1 -1
- package/dist/apps/data/App.js +135 -44
- package/dist/apps/data/App.js.map +1 -1
- package/dist/apps/data/FundingQuoteProviders.d.ts +36 -0
- package/dist/apps/data/FundingQuoteProviders.d.ts.map +1 -0
- package/dist/apps/data/FundingQuoteProviders.js +190 -0
- package/dist/apps/data/FundingQuoteProviders.js.map +1 -0
- package/dist/apps/data/index.d.ts +1 -1
- package/dist/apps/data/index.js +1 -1
- package/dist/apps/data/routes/activities.d.ts +242 -242
- package/dist/apps/data/routes/addresses.d.ts +227 -227
- package/dist/apps/data/routes/balances.d.ts +12 -12
- package/dist/apps/data/routes/balances.d.ts.map +1 -1
- package/dist/apps/data/routes/balances.js +67 -149
- package/dist/apps/data/routes/balances.js.map +1 -1
- package/dist/apps/data/routes/blocks.d.ts +125 -126
- package/dist/apps/data/routes/blocks.d.ts.map +1 -1
- package/dist/apps/data/routes/blocks.js +3 -5
- package/dist/apps/data/routes/blocks.js.map +1 -1
- package/dist/apps/data/routes/coingecko.d.ts +55 -54
- package/dist/apps/data/routes/coingecko.d.ts.map +1 -1
- package/dist/apps/data/routes/coingecko.js +3 -8
- package/dist/apps/data/routes/coingecko.js.map +1 -1
- package/dist/apps/data/routes/exchanges.d.ts +455 -455
- package/dist/apps/data/routes/fee-amm.d.ts +92 -92
- package/dist/apps/data/routes/funding.d.ts +91 -864
- package/dist/apps/data/routes/funding.d.ts.map +1 -1
- package/dist/apps/data/routes/funding.js +138 -450
- package/dist/apps/data/routes/funding.js.map +1 -1
- package/dist/apps/data/routes/rpc.d.ts.map +1 -1
- package/dist/apps/data/routes/rpc.js +86 -16
- package/dist/apps/data/routes/rpc.js.map +1 -1
- package/dist/apps/data/routes/tokenlist.d.ts +36 -36
- package/dist/apps/data/routes/webhooks.d.ts +340 -0
- package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
- package/dist/apps/data/routes/webhooks.js +13 -1
- package/dist/apps/data/routes/webhooks.js.map +1 -1
- package/dist/apps/index.d.ts +1 -1
- package/dist/apps/index.js +1 -1
- package/dist/apps/management/App.d.ts +1843 -861
- package/dist/apps/management/App.d.ts.map +1 -1
- package/dist/apps/management/App.js +4 -0
- package/dist/apps/management/App.js.map +1 -1
- package/dist/apps/management/routes/api-keys.d.ts +121 -121
- package/dist/apps/management/routes/api-keys.js +2 -2
- package/dist/apps/management/routes/api-keys.js.map +1 -1
- package/dist/apps/management/routes/billing.d.ts +2 -2
- package/dist/apps/management/routes/invite-links.d.ts +1100 -0
- package/dist/apps/management/routes/invite-links.d.ts.map +1 -0
- package/dist/apps/management/routes/invite-links.js +411 -0
- package/dist/apps/management/routes/invite-links.js.map +1 -0
- package/dist/apps/management/routes/me.d.ts +13 -13
- package/dist/apps/management/routes/members.d.ts +77 -77
- package/dist/apps/management/routes/orgs.d.ts +96 -96
- package/dist/apps/management/routes/projects.d.ts +62 -62
- package/dist/apps/management/routes/scopes.d.ts +11 -11
- package/dist/apps/management/routes/scopes.d.ts.map +1 -1
- package/dist/apps/management/routes/scopes.js +2 -2
- package/dist/apps/management/routes/scopes.js.map +1 -1
- package/dist/apps/management/routes/usage.d.ts +79 -79
- package/dist/apps/mcp.d.ts.map +1 -1
- package/dist/apps/mcp.js +3 -1
- package/dist/apps/mcp.js.map +1 -1
- package/dist/apps/relay/App.d.ts.map +1 -1
- package/dist/apps/relay/App.js +42 -4
- package/dist/apps/relay/App.js.map +1 -1
- package/dist/cloudflare.d.ts +2 -0
- package/dist/cloudflare.d.ts.map +1 -1
- package/dist/cloudflare.js +4 -0
- package/dist/cloudflare.js.map +1 -1
- package/dist/db/Db.d.ts +3 -0
- package/dist/db/Db.d.ts.map +1 -1
- package/dist/db/Db.js +57 -0
- package/dist/db/Db.js.map +1 -1
- package/dist/db/Schema.d.ts +31 -0
- package/dist/db/Schema.d.ts.map +1 -1
- package/dist/db/Schema.js +31 -0
- package/dist/db/Schema.js.map +1 -1
- package/dist/db/tables/inviteLinks.d.ts +42 -0
- package/dist/db/tables/inviteLinks.d.ts.map +1 -0
- package/dist/db/tables/inviteLinks.js +148 -0
- package/dist/db/tables/inviteLinks.js.map +1 -0
- package/dist/db/tables/organizations.d.ts.map +1 -1
- package/dist/db/tables/organizations.js +2 -0
- package/dist/db/tables/organizations.js.map +1 -1
- package/dist/handlers/relay.d.ts.map +1 -1
- package/dist/handlers/relay.js +13 -3
- package/dist/handlers/relay.js.map +1 -1
- package/dist/internal/Auth.d.ts +42 -23
- package/dist/internal/Auth.d.ts.map +1 -1
- package/dist/internal/Auth.js +169 -20
- package/dist/internal/Auth.js.map +1 -1
- package/dist/internal/EdgeCache.d.ts +3 -1
- package/dist/internal/EdgeCache.d.ts.map +1 -1
- package/dist/internal/EdgeCache.js +17 -2
- package/dist/internal/EdgeCache.js.map +1 -1
- package/dist/internal/Errors.d.ts +6 -0
- package/dist/internal/Errors.d.ts.map +1 -0
- package/dist/internal/Errors.js +16 -0
- package/dist/internal/Errors.js.map +1 -0
- package/dist/internal/FundingQuotes.d.ts +644 -0
- package/dist/internal/FundingQuotes.d.ts.map +1 -0
- package/dist/internal/{FundingRoutes.js → FundingQuotes.js} +93 -289
- package/dist/internal/FundingQuotes.js.map +1 -0
- package/dist/internal/Store.d.ts +61 -16
- package/dist/internal/Store.d.ts.map +1 -1
- package/dist/internal/Store.js +72 -17
- package/dist/internal/Store.js.map +1 -1
- package/dist/internal/Viem.d.ts +2 -0
- package/dist/internal/Viem.d.ts.map +1 -1
- package/dist/internal/Viem.js +11 -2
- package/dist/internal/Viem.js.map +1 -1
- package/dist/internal/index.d.ts +27 -0
- package/dist/internal/index.d.ts.map +1 -0
- package/dist/internal/index.js +27 -0
- package/dist/internal/index.js.map +1 -0
- package/package.json +8 -3
- package/src/ApiKeys.test.ts +23 -0
- package/src/App.test-d.ts +2 -0
- package/src/App.test.ts +60 -4
- package/src/App.ts +6 -0
- package/src/Client.test-d.ts +16 -54
- package/src/Scope.test.ts +1 -0
- package/src/Scope.ts +30 -4
- package/src/apps/data/App.test.ts +354 -0
- package/src/apps/data/App.ts +159 -52
- package/src/apps/data/FundingQuoteProviders.test.ts +151 -0
- package/src/apps/data/FundingQuoteProviders.ts +251 -0
- package/src/apps/data/index.ts +1 -1
- package/src/apps/data/routes/balances.test.ts +64 -18
- package/src/apps/data/routes/balances.ts +76 -177
- package/src/apps/data/routes/blocks.ts +3 -5
- package/src/apps/data/routes/coingecko.ts +3 -8
- package/src/apps/data/routes/funding.test.ts +149 -306
- package/src/apps/data/routes/funding.ts +154 -604
- package/src/apps/data/routes/indexer.test.ts +99 -3
- package/src/apps/data/routes/rpc.test.ts +219 -0
- package/src/apps/data/routes/rpc.ts +92 -17
- package/src/apps/data/routes/webhooks.test.ts +23 -2
- package/src/apps/data/routes/webhooks.ts +13 -1
- package/src/apps/index.ts +1 -1
- package/src/apps/management/App.ts +4 -0
- package/src/apps/management/routes/api-keys.test.ts +47 -3
- package/src/apps/management/routes/api-keys.ts +2 -2
- package/src/apps/management/routes/invite-links.test.ts +385 -0
- package/src/apps/management/routes/invite-links.ts +570 -0
- package/src/apps/management/routes/scopes.test.ts +3 -2
- package/src/apps/management/routes/scopes.ts +4 -2
- package/src/apps/mcp.test.ts +55 -14
- package/src/apps/mcp.ts +3 -1
- package/src/apps/relay/App.test.ts +324 -4
- package/src/apps/relay/App.ts +40 -4
- package/src/cloudflare.ts +5 -0
- package/src/db/Db.ts +65 -0
- package/src/db/Schema.ts +39 -0
- package/src/db/tables/inviteLinks.ts +187 -0
- package/src/db/tables/organizations.ts +2 -0
- package/src/handlers/relay.ts +16 -4
- package/src/internal/Auth.test.ts +528 -37
- package/src/internal/Auth.ts +246 -45
- package/src/internal/EdgeCache.test.ts +24 -3
- package/src/internal/EdgeCache.ts +18 -2
- package/src/internal/FundingQuotes.test.ts +174 -0
- package/src/internal/FundingQuotes.ts +735 -0
- package/src/internal/Log.test.ts +4 -4
- package/src/internal/Store.test-d.ts +15 -0
- package/src/internal/Store.test.ts +153 -2
- package/src/internal/Store.ts +157 -41
- package/src/internal/Viem.ts +14 -5
- package/src/internal/index.test.ts +34 -0
- package/src/internal/index.ts +26 -0
- package/dist/apps/data/FundingRouteProviders.d.ts +0 -143
- package/dist/apps/data/FundingRouteProviders.d.ts.map +0 -1
- package/dist/apps/data/FundingRouteProviders.js +0 -546
- package/dist/apps/data/FundingRouteProviders.js.map +0 -1
- package/dist/internal/FundingRoutes.d.ts +0 -1085
- package/dist/internal/FundingRoutes.d.ts.map +0 -1
- package/dist/internal/FundingRoutes.js.map +0 -1
- package/src/apps/data/FundingRouteProviders.test.ts +0 -485
- package/src/apps/data/FundingRouteProviders.ts +0 -744
- package/src/internal/FundingRoutes.test.ts +0 -239
- package/src/internal/FundingRoutes.ts +0 -1067
package/src/App.test.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import * as http from 'node:http'
|
|
2
|
+
import { Challenge } from 'mppx'
|
|
2
3
|
import { ApiKeys, App, Metrics, RateLimit, Store } from 'tapimo'
|
|
3
4
|
import * as Docs from 'tapimo/docs'
|
|
4
5
|
import * as Tokens from './apps/data/routes/tokens.js'
|
|
5
6
|
import type * as Log from './internal/Log.js'
|
|
6
7
|
import * as TestApp from '../test/App.js'
|
|
7
8
|
import * as Runtime from '../test/runtime.js'
|
|
9
|
+
import * as Tempo from '../test/Tempo.js'
|
|
8
10
|
|
|
9
11
|
describe('create', () => {
|
|
10
12
|
test('composes default dependencies', async () => {
|
|
@@ -208,6 +210,53 @@ describe('create', () => {
|
|
|
208
210
|
`)
|
|
209
211
|
})
|
|
210
212
|
|
|
213
|
+
test('applies the configured global MPP rate limit', async () => {
|
|
214
|
+
const store = Store.memory()
|
|
215
|
+
const limit = { perMinute: 2 }
|
|
216
|
+
const app = TestApp.create({
|
|
217
|
+
mpp: { rateLimit: limit },
|
|
218
|
+
rateLimit: { store },
|
|
219
|
+
})
|
|
220
|
+
await exhaustQuota(RateLimit.memory({ store }), 'mpp:public:203.0.113.10', limit)
|
|
221
|
+
|
|
222
|
+
const response = await app.request(`/v1/tokens/${TestApp.token}`, {
|
|
223
|
+
headers: {
|
|
224
|
+
authorization: 'Payment malformed',
|
|
225
|
+
'x-forwarded-for': '203.0.113.10',
|
|
226
|
+
},
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
expect(response.status).toBe(429)
|
|
230
|
+
expect(response.headers.get('RateLimit-Limit')).toBe('2')
|
|
231
|
+
expect(response.headers.get('RateLimit-Scope')).toBe('mpp')
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test('endpoint MPP rate limit overrides the global limit', async () => {
|
|
235
|
+
const store = Store.memory()
|
|
236
|
+
const routeLimit = { perMinute: 1 }
|
|
237
|
+
const app = TestApp.create({
|
|
238
|
+
auth: {
|
|
239
|
+
overrides: {
|
|
240
|
+
'GET /v1/tokens/:token': { mpp: { rateLimit: routeLimit } },
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
mpp: { rateLimit: { perMinute: 2 } },
|
|
244
|
+
rateLimit: { store },
|
|
245
|
+
})
|
|
246
|
+
await exhaustQuota(RateLimit.memory({ store }), 'mpp:public:203.0.113.10', routeLimit)
|
|
247
|
+
|
|
248
|
+
const response = await app.request(`/v1/tokens/${TestApp.token}`, {
|
|
249
|
+
headers: {
|
|
250
|
+
authorization: 'Payment malformed',
|
|
251
|
+
'x-forwarded-for': '203.0.113.10',
|
|
252
|
+
},
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
expect(response.status).toBe(429)
|
|
256
|
+
expect(response.headers.get('RateLimit-Limit')).toBe('1')
|
|
257
|
+
expect(response.headers.get('RateLimit-Scope')).toBe('mpp')
|
|
258
|
+
})
|
|
259
|
+
|
|
211
260
|
test('serves Vocs API reference', async () => {
|
|
212
261
|
const app = TestApp.create({ docs: Docs.docs() })
|
|
213
262
|
const response = await app.request('/')
|
|
@@ -602,11 +651,13 @@ describe('create', () => {
|
|
|
602
651
|
auth,
|
|
603
652
|
cache: { store },
|
|
604
653
|
mpp: {
|
|
605
|
-
charge: {
|
|
606
|
-
currency: 'USD',
|
|
607
|
-
decimals: 2,
|
|
608
|
-
},
|
|
609
654
|
secretKey: 'secret_test_key_0123456789abcdef',
|
|
655
|
+
session: {
|
|
656
|
+
chainId: Tempo.chain.id,
|
|
657
|
+
decimals: 6,
|
|
658
|
+
getClient: () => Tempo.client,
|
|
659
|
+
recipient: Tempo.accounts[2].address,
|
|
660
|
+
},
|
|
610
661
|
},
|
|
611
662
|
rateLimit: {
|
|
612
663
|
now,
|
|
@@ -627,6 +678,11 @@ describe('create', () => {
|
|
|
627
678
|
})
|
|
628
679
|
const overQuota = await app.request('/v1/tokens/not-an-address')
|
|
629
680
|
expect(overQuota.status).toMatchInlineSnapshot(`402`)
|
|
681
|
+
expect(Challenge.fromHeaders(overQuota.headers).request['currency']).toBe(Tempo.currency)
|
|
682
|
+
|
|
683
|
+
const head = await app.request('/v1/tokens/not-an-address', { method: 'HEAD' })
|
|
684
|
+
expect(head.status).toMatchInlineSnapshot(`402`)
|
|
685
|
+
expect(Challenge.fromHeaders(head.headers).intent).toBe('session')
|
|
630
686
|
})
|
|
631
687
|
|
|
632
688
|
test('supports bearer API-key fallback', async () => {
|
package/src/App.ts
CHANGED
|
@@ -72,6 +72,8 @@ export type Environment = {
|
|
|
72
72
|
tidx: Tidx.getClient.Tidx | undefined
|
|
73
73
|
/** Verified-token feature configuration (refresh window), or undefined when the feature is disabled. Token rows live in `db`. */
|
|
74
74
|
verifiedTokens: { refreshMs?: number | undefined } | undefined
|
|
75
|
+
/** Zone RPC endpoints keyed by chain id; empty when none are configured. */
|
|
76
|
+
zones: ReadonlyMap<number, Data.data.Zone>
|
|
75
77
|
/** Webhook feature configuration, or undefined when webhooks are disabled. Rows live in `db`. */
|
|
76
78
|
webhook: { maxPerOwner?: number | undefined } | undefined
|
|
77
79
|
} & EdgeCache.Variables &
|
|
@@ -398,6 +400,10 @@ export function create<const path extends string = '/'>(options: create.Options<
|
|
|
398
400
|
let finalized = false
|
|
399
401
|
const finalize = () => {
|
|
400
402
|
if (finalized) return
|
|
403
|
+
Auth.installMppManagementRoutes(app, {
|
|
404
|
+
basePath,
|
|
405
|
+
overrides: options.auth === false ? undefined : options.auth?.overrides,
|
|
406
|
+
})
|
|
401
407
|
finalized = true
|
|
402
408
|
|
|
403
409
|
app.get('/openapi.json', async (c) => c.json(await getSpec()))
|
package/src/Client.test-d.ts
CHANGED
|
@@ -1189,25 +1189,27 @@ describe('narrows responses by status', () => {
|
|
|
1189
1189
|
}
|
|
1190
1190
|
})
|
|
1191
1191
|
|
|
1192
|
-
test('GET /v1/funding/
|
|
1192
|
+
test('GET /v1/funding/quotes', async () => {
|
|
1193
1193
|
const client = Client.create()
|
|
1194
|
-
const res = await client.v1.funding.
|
|
1194
|
+
const res = await client.v1.funding.quotes.$get({
|
|
1195
1195
|
query: {
|
|
1196
|
+
provider: 'relay',
|
|
1196
1197
|
sourceAmount: '1000000',
|
|
1197
1198
|
sourceChain: 'base',
|
|
1198
1199
|
sourceToken: 'USDC',
|
|
1199
1200
|
},
|
|
1200
1201
|
})
|
|
1201
1202
|
|
|
1202
|
-
expectTypeOf(res.status).toEqualTypeOf<200 | 400 | PaidAccessStatus>()
|
|
1203
|
+
expectTypeOf(res.status).toEqualTypeOf<200 | 400 | 502 | PaidAccessStatus>()
|
|
1203
1204
|
|
|
1204
1205
|
switch (res.status) {
|
|
1205
1206
|
case 200: {
|
|
1206
1207
|
const json = await res.json()
|
|
1207
1208
|
expectTypeOf(json.data[0]!.sourceAmount).toEqualTypeOf<
|
|
1208
|
-
z.output<typeof Funding.schema.
|
|
1209
|
+
z.output<typeof Funding.schema.quoteFunding.Response>['data'][number]['sourceAmount']
|
|
1209
1210
|
>()
|
|
1210
|
-
expectTypeOf(json.data[0]!.provider.id).toEqualTypeOf<
|
|
1211
|
+
expectTypeOf(json.data[0]!.provider.id).toEqualTypeOf<'relay'>()
|
|
1212
|
+
expectTypeOf(json.data[0]!.quote.outputAmount).toEqualTypeOf<string>()
|
|
1211
1213
|
void json
|
|
1212
1214
|
return
|
|
1213
1215
|
}
|
|
@@ -1218,51 +1220,11 @@ describe('narrows responses by status', () => {
|
|
|
1218
1220
|
void json
|
|
1219
1221
|
return
|
|
1220
1222
|
}
|
|
1221
|
-
}
|
|
1222
|
-
})
|
|
1223
|
-
|
|
1224
|
-
test('POST /v1/funding/routes/quote', async () => {
|
|
1225
|
-
const client = Client.create()
|
|
1226
|
-
const res = await client.v1.funding.routes.quote.$post({
|
|
1227
|
-
json: {
|
|
1228
|
-
depositor: '0x1111111111111111111111111111111111111111',
|
|
1229
|
-
provider: 'across',
|
|
1230
|
-
sourceAmount: '1000000',
|
|
1231
|
-
sourceChain: 'base',
|
|
1232
|
-
sourceToken: 'USDC',
|
|
1233
|
-
},
|
|
1234
|
-
})
|
|
1235
|
-
|
|
1236
|
-
expectTypeOf(res.status).toEqualTypeOf<200 | 400 | 404 | 501 | 502 | PaidAccessStatus>()
|
|
1237
|
-
|
|
1238
|
-
switch (res.status) {
|
|
1239
|
-
case 200: {
|
|
1240
|
-
const json = await res.json()
|
|
1241
|
-
expectTypeOf(json).toEqualTypeOf<
|
|
1242
|
-
z.output<typeof Funding.schema.quoteFundingRoute.Response>
|
|
1243
|
-
>()
|
|
1244
|
-
return
|
|
1245
|
-
}
|
|
1246
|
-
case 400: {
|
|
1247
|
-
const json = await res.json()
|
|
1248
|
-
expectTypeOf(json.error.code).toEqualTypeOf<Access400<'body_invalid'>>()
|
|
1249
|
-
return
|
|
1250
|
-
}
|
|
1251
|
-
case 404: {
|
|
1252
|
-
const json = await res.json()
|
|
1253
|
-
expectTypeOf(json.error.code).toEqualTypeOf<'quote_unavailable' | 'route_not_found'>()
|
|
1254
|
-
return
|
|
1255
|
-
}
|
|
1256
|
-
case 501: {
|
|
1257
|
-
const json = await res.json()
|
|
1258
|
-
expectTypeOf(json.error.code).toEqualTypeOf<'provider_unconfigured'>()
|
|
1259
|
-
return
|
|
1260
|
-
}
|
|
1261
1223
|
case 502: {
|
|
1262
1224
|
const json = await res.json()
|
|
1263
|
-
expectTypeOf(json.error.code).toEqualTypeOf<
|
|
1264
|
-
|
|
1265
|
-
|
|
1225
|
+
expectTypeOf(json.error.code).toEqualTypeOf<'upstream_error'>()
|
|
1226
|
+
expectTypeOf(json.error.message).toEqualTypeOf<string>()
|
|
1227
|
+
void json
|
|
1266
1228
|
return
|
|
1267
1229
|
}
|
|
1268
1230
|
}
|
|
@@ -1295,24 +1257,24 @@ describe('narrows responses by status', () => {
|
|
|
1295
1257
|
}
|
|
1296
1258
|
})
|
|
1297
1259
|
|
|
1298
|
-
test('GET /v1/providers', async () => {
|
|
1260
|
+
test('GET /v1/funding/providers', async () => {
|
|
1299
1261
|
const client = Client.create()
|
|
1300
|
-
const res = await client.v1.providers.$get(
|
|
1262
|
+
const res = await client.v1.funding.providers.$get()
|
|
1301
1263
|
|
|
1302
|
-
expectTypeOf(res.status).toEqualTypeOf<200 |
|
|
1264
|
+
expectTypeOf(res.status).toEqualTypeOf<200 | PaidAccessStatus>()
|
|
1303
1265
|
|
|
1304
1266
|
switch (res.status) {
|
|
1305
1267
|
case 200: {
|
|
1306
1268
|
const json = await res.json()
|
|
1307
|
-
expectTypeOf(json.data[0]!.
|
|
1308
|
-
z.output<typeof Funding.schema.listProviders.Response>['data'][number]['
|
|
1269
|
+
expectTypeOf(json.data[0]!.status).toEqualTypeOf<
|
|
1270
|
+
z.output<typeof Funding.schema.listProviders.Response>['data'][number]['status']
|
|
1309
1271
|
>()
|
|
1310
1272
|
void json
|
|
1311
1273
|
return
|
|
1312
1274
|
}
|
|
1313
1275
|
case 400: {
|
|
1314
1276
|
const json = await res.json()
|
|
1315
|
-
expectTypeOf(json.error.code).toEqualTypeOf<
|
|
1277
|
+
expectTypeOf(json.error.code).toEqualTypeOf<'api_key_malformed'>()
|
|
1316
1278
|
expectTypeOf(json.error.message).toEqualTypeOf<string>()
|
|
1317
1279
|
void json
|
|
1318
1280
|
return
|
package/src/Scope.test.ts
CHANGED
package/src/Scope.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* API-key validation.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import * as z from 'zod/mini'
|
|
8
|
+
|
|
7
9
|
/**
|
|
8
10
|
* Wildcard scope. A key whose `scopes` contains this value satisfies every
|
|
9
11
|
* required scope, so it never needs re-provisioning when new scopes are added.
|
|
@@ -69,8 +71,30 @@ export const catalog = [
|
|
|
69
71
|
},
|
|
70
72
|
] as const satisfies readonly Entry[]
|
|
71
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Templated `/v1/scopes` entry for per-zone read scopes. Concrete grants use
|
|
76
|
+
* `zone:<chainId>:read` and are never self-serve.
|
|
77
|
+
*/
|
|
78
|
+
export const zoneEntry = {
|
|
79
|
+
description: 'Read RPC and indexed data for one zone chain id.',
|
|
80
|
+
scope: 'zone:<chainId>:read',
|
|
81
|
+
selfServe: false,
|
|
82
|
+
} as const satisfies Entry
|
|
83
|
+
|
|
84
|
+
/** Zod schemas owned by the scope vocabulary. */
|
|
85
|
+
export namespace schema {
|
|
86
|
+
/** A concrete per-zone read scope (`zone:421700001:read`). */
|
|
87
|
+
export const Zone = z
|
|
88
|
+
.templateLiteral(['zone:', z.int(), ':read'])
|
|
89
|
+
// `z.int()` also matches a sign; zone chain ids are positive.
|
|
90
|
+
.check(z.regex(/^zone:\d+:read$/), z.describe('Per-zone read scope (`zone:<chainId>:read`).'))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Per-zone read scope, granting access to one zone chain id. */
|
|
94
|
+
export type Zone = z.output<typeof schema.Zone>
|
|
95
|
+
|
|
72
96
|
/** Issuable API-key scope identifier, including host-defined scopes. */
|
|
73
|
-
export type Id = (typeof catalog)[number]['scope'] | (string & {})
|
|
97
|
+
export type Id = (typeof catalog)[number]['scope'] | Zone | (string & {})
|
|
74
98
|
|
|
75
99
|
/** Scope catalog used by API-key issuance and discovery. */
|
|
76
100
|
export type Catalog = readonly Entry[]
|
|
@@ -92,7 +116,7 @@ export const selfServe: readonly Id[] = catalog
|
|
|
92
116
|
*/
|
|
93
117
|
export function extend(entries: readonly Entry[] = []): Catalog {
|
|
94
118
|
const identifiers = new Set<string>()
|
|
95
|
-
const result = [...catalog, ...entries]
|
|
119
|
+
const result = [...catalog, zoneEntry, ...entries]
|
|
96
120
|
for (const entry of result) {
|
|
97
121
|
if (!entry.scope.trim()) throw new Error('Scope identifier must not be empty.')
|
|
98
122
|
if (identifiers.has(entry.scope)) throw new Error(`Duplicate scope \`${entry.scope}\`.`)
|
|
@@ -103,10 +127,12 @@ export function extend(entries: readonly Entry[] = []): Catalog {
|
|
|
103
127
|
|
|
104
128
|
/** Returns whether a catalog contains a scope identifier. */
|
|
105
129
|
export function includes(catalog: Catalog, scope: string): scope is Id {
|
|
106
|
-
return catalog.some((entry) => entry.scope === scope)
|
|
130
|
+
return schema.Zone.safeParse(scope).success || catalog.some((entry) => entry.scope === scope)
|
|
107
131
|
}
|
|
108
132
|
|
|
109
133
|
/** Returns the self-issuable identifiers in a scope catalog. */
|
|
110
134
|
export function selfServeFrom(catalog: Catalog): readonly Id[] {
|
|
111
|
-
return catalog
|
|
135
|
+
return catalog
|
|
136
|
+
.filter((entry) => entry.selfServe && !schema.Zone.safeParse(entry.scope).success)
|
|
137
|
+
.map((entry) => entry.scope)
|
|
112
138
|
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
+
import { Secp256k1 } from 'ox'
|
|
2
|
+
import { ZoneRpcAuthentication } from 'ox/tempo'
|
|
3
|
+
import { Challenge, PaymentRequest } from 'mppx'
|
|
4
|
+
import { RateLimit } from 'tapimo'
|
|
5
|
+
|
|
1
6
|
import * as TestApp from '../../../test/App.js'
|
|
7
|
+
import * as Tempo from '../../../test/Tempo.js'
|
|
8
|
+
import * as Store from '../../internal/Store.js'
|
|
2
9
|
import * as Viem from '../../internal/Viem.js'
|
|
3
10
|
|
|
4
11
|
/** A supported non-mainnet chain id, exercising the sandbox testnet allowance. */
|
|
@@ -35,6 +42,346 @@ function mainnetDefaultClient() {
|
|
|
35
42
|
})
|
|
36
43
|
}
|
|
37
44
|
|
|
45
|
+
type OpenApiDocument = {
|
|
46
|
+
paths: Record<string, Record<string, unknown>>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const mpp = {
|
|
50
|
+
secretKey: 'secret_test_key_0123456789abcdef',
|
|
51
|
+
session: {
|
|
52
|
+
chainId: Tempo.chain.id,
|
|
53
|
+
currency: Tempo.currency,
|
|
54
|
+
decimals: 6,
|
|
55
|
+
getClient: () => Tempo.client,
|
|
56
|
+
recipient: Tempo.accounts[2].address,
|
|
57
|
+
},
|
|
58
|
+
} satisfies NonNullable<TestApp.create.Options['mpp']>
|
|
59
|
+
|
|
60
|
+
describe('MPP session management', () => {
|
|
61
|
+
test('keeps generated POST routes private', async () => {
|
|
62
|
+
const app = TestApp.create({ auth: false })
|
|
63
|
+
const indexer = await app.request('/v1/indexer/query', { method: 'POST' })
|
|
64
|
+
const token = await app.request(`/v1/tokens/${TestApp.token}`, { method: 'POST' })
|
|
65
|
+
const spec = (await (await app.request('/openapi.json')).json()) as OpenApiDocument
|
|
66
|
+
|
|
67
|
+
expect(indexer.status).toBe(404)
|
|
68
|
+
expect(token.status).toBe(404)
|
|
69
|
+
expect(spec.paths['/v1/indexer/query']).not.toHaveProperty('post')
|
|
70
|
+
expect(spec.paths['/v1/tokens/{token}']).not.toHaveProperty('post')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('matches GET scopes for static, parameterized, and hidden resources', async () => {
|
|
74
|
+
const rateLimitStore = Store.memory()
|
|
75
|
+
const app = TestApp.create({ mpp, rateLimit: { store: rateLimitStore } })
|
|
76
|
+
await exhaustPublicQuota(rateLimitStore)
|
|
77
|
+
|
|
78
|
+
for (const resource of [
|
|
79
|
+
{
|
|
80
|
+
path: `/v1/indexer/query?sql=select%201&chainId=${Tempo.chain.id}`,
|
|
81
|
+
scope: 'GET /v1/indexer/query',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
path: `/v1/tokens/${TestApp.token}?chainId=${Tempo.chain.id}`,
|
|
85
|
+
scope: 'GET /v1/tokens/:token',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
path: `/v1/verified-tokens?chainId=${Tempo.chain.id}`,
|
|
89
|
+
scope: 'GET /v1/verified-tokens',
|
|
90
|
+
},
|
|
91
|
+
]) {
|
|
92
|
+
const url = new URL(resource.path, 'http://tempo-api.test')
|
|
93
|
+
const challenged = await app.fetch(new Request(url))
|
|
94
|
+
url.search = ''
|
|
95
|
+
const management = await app.fetch(new Request(url, { method: 'POST' }))
|
|
96
|
+
|
|
97
|
+
expect(challenged.status).toBe(402)
|
|
98
|
+
expect(management.status).toBe(402)
|
|
99
|
+
const expectedScope = PaymentRequest.serialize({
|
|
100
|
+
_mppx_scope: resource.scope,
|
|
101
|
+
})
|
|
102
|
+
expect(Challenge.fromHeaders(challenged.headers).opaque).toBe(expectedScope)
|
|
103
|
+
expect(Challenge.fromHeaders(management.headers).opaque).toBe(expectedScope)
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('preserves the mounted base path in session scope', async () => {
|
|
108
|
+
const rateLimitStore = Store.memory()
|
|
109
|
+
const app = TestApp.create({ mpp, path: '/api', rateLimit: { store: rateLimitStore } })
|
|
110
|
+
await exhaustPublicQuota(rateLimitStore)
|
|
111
|
+
|
|
112
|
+
const resource = new URL(
|
|
113
|
+
`/api/v1/tokens/${TestApp.token}?chainId=${Tempo.chain.id}`,
|
|
114
|
+
'http://tempo-api.test',
|
|
115
|
+
)
|
|
116
|
+
const challenged = await app.fetch(new Request(resource))
|
|
117
|
+
resource.search = ''
|
|
118
|
+
const management = await app.fetch(new Request(resource, { method: 'POST' }))
|
|
119
|
+
|
|
120
|
+
expect(challenged.status).toBe(402)
|
|
121
|
+
expect(management.status).toBe(402)
|
|
122
|
+
const expectedScope = PaymentRequest.serialize({
|
|
123
|
+
_mppx_scope: 'GET /api/v1/tokens/:token',
|
|
124
|
+
})
|
|
125
|
+
expect(Challenge.fromHeaders(challenged.headers).opaque).toBe(expectedScope)
|
|
126
|
+
expect(Challenge.fromHeaders(management.headers).opaque).toBe(expectedScope)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('honors endpoint MPP overrides', async () => {
|
|
130
|
+
const app = TestApp.create({
|
|
131
|
+
auth: {
|
|
132
|
+
overrides: {
|
|
133
|
+
'GET /v1/scopes': { mpp: true },
|
|
134
|
+
'GET /v1/tokens/:token': { mpp: false },
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
mpp,
|
|
138
|
+
})
|
|
139
|
+
const enabled = await app.request('/v1/scopes', { method: 'POST' })
|
|
140
|
+
const disabled = await app.request(`/v1/tokens/${TestApp.token}`, { method: 'POST' })
|
|
141
|
+
|
|
142
|
+
expect(enabled.status).toBe(402)
|
|
143
|
+
expect(disabled.status).toBe(404)
|
|
144
|
+
expect(Challenge.fromHeaders(enabled.headers).opaque).toBe(
|
|
145
|
+
PaymentRequest.serialize({ _mppx_scope: 'GET /v1/scopes' }),
|
|
146
|
+
)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe('zone chain gate', () => {
|
|
151
|
+
/** A consumer-supplied zone chain id (the API never derives these). */
|
|
152
|
+
const zone = 421_700_001
|
|
153
|
+
/** A second zone, isolating per-zone grants. */
|
|
154
|
+
const zoneB = 421_700_002
|
|
155
|
+
|
|
156
|
+
function zoneOptions(chainId: number) {
|
|
157
|
+
return {
|
|
158
|
+
chainId,
|
|
159
|
+
forwardRpcUrl: `https://${chainId}.rpc.test`,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Zone reader: holds the per-zone scope for `zone` (only). */
|
|
164
|
+
const zoneReader = {
|
|
165
|
+
id: 'key_zone',
|
|
166
|
+
orgId: 'org_test',
|
|
167
|
+
scopes: ['data:read', `zone:${zone}:read`],
|
|
168
|
+
token: 'secret_zone',
|
|
169
|
+
} satisfies TestApp.kvStore.Key
|
|
170
|
+
|
|
171
|
+
/** Full-access key: the wildcard covers every zone. */
|
|
172
|
+
const admin = {
|
|
173
|
+
id: 'key_admin',
|
|
174
|
+
orgId: 'org_test',
|
|
175
|
+
scopes: ['*'],
|
|
176
|
+
token: 'secret_admin',
|
|
177
|
+
} satisfies TestApp.kvStore.Key
|
|
178
|
+
|
|
179
|
+
/** Data reader without any zone grant. */
|
|
180
|
+
const outsider = {
|
|
181
|
+
id: 'key_outsider',
|
|
182
|
+
orgId: 'org_test',
|
|
183
|
+
scopes: ['data:read'],
|
|
184
|
+
token: 'secret_outsider',
|
|
185
|
+
} satisfies TestApp.kvStore.Key
|
|
186
|
+
|
|
187
|
+
/** A deployment serving two zones. */
|
|
188
|
+
function zoneApp() {
|
|
189
|
+
return TestApp.create({
|
|
190
|
+
auth: { keys: [admin, outsider, zoneReader] },
|
|
191
|
+
zones: [zoneOptions(zone), zoneOptions(zoneB)],
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Signing key proving that a structurally valid caller token is insufficient for REST. */
|
|
196
|
+
const privateKey = Secp256k1.randomPrivateKey()
|
|
197
|
+
|
|
198
|
+
/** Signs a caller Zone token. */
|
|
199
|
+
function zoneToken(chainId: number) {
|
|
200
|
+
const issuedAt = Math.floor(Date.now() / 1000)
|
|
201
|
+
const authentication = ZoneRpcAuthentication.from({
|
|
202
|
+
chainId,
|
|
203
|
+
expiresAt: issuedAt + 300,
|
|
204
|
+
issuedAt,
|
|
205
|
+
zoneId: 1,
|
|
206
|
+
})
|
|
207
|
+
return ZoneRpcAuthentication.serialize(authentication, {
|
|
208
|
+
signature: Secp256k1.sign({
|
|
209
|
+
payload: ZoneRpcAuthentication.getSignPayload(authentication),
|
|
210
|
+
privateKey,
|
|
211
|
+
}),
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
test('zone chain ids are additive: unsupported ids list them as supported', async () => {
|
|
216
|
+
const app = TestApp.create({ auth: false, zones: [zoneOptions(zone)] })
|
|
217
|
+
const response = await app.request('/v1/transfers?chainId=999999')
|
|
218
|
+
const body = (await response.json()) as { error: { code: string; message: string } }
|
|
219
|
+
|
|
220
|
+
expect(response.status).toBe(400)
|
|
221
|
+
expect(body.error.code).toBe('chain_id_unsupported')
|
|
222
|
+
expect(body.error.message).toContain(String(zone))
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('a default zone still applies authorization', async () => {
|
|
226
|
+
const app = TestApp.create({
|
|
227
|
+
auth: false,
|
|
228
|
+
defaultChainId: zone,
|
|
229
|
+
rpc: { url: (chainId) => `https://${chainId}.rpc.test` },
|
|
230
|
+
zones: [zoneOptions(zone)],
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
const rpc = await app.request('/rpc', {
|
|
234
|
+
body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }),
|
|
235
|
+
method: 'POST',
|
|
236
|
+
})
|
|
237
|
+
const transfers = await app.request('/v1/transfers')
|
|
238
|
+
const tokens = await app.request('/v1/tokens')
|
|
239
|
+
const scopes = await app.request('/v1/scopes')
|
|
240
|
+
|
|
241
|
+
expect(rpc.status).toBe(403)
|
|
242
|
+
expect(transfers.status).toBe(403)
|
|
243
|
+
expect(tokens.status).toBe(403)
|
|
244
|
+
expect(scopes.status).toBe(200)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test('a zone-scoped key reads Zone data', async () => {
|
|
248
|
+
const response = await zoneApp().request(`/v1/tokenlist?chainId=${zone}`, as(zoneReader))
|
|
249
|
+
expect(response.status).toBe(200)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
test('zone authorization applies to every data route', async () => {
|
|
253
|
+
const app = TestApp.create({ auth: false, zones: [zoneOptions(zone)] })
|
|
254
|
+
for (const path of [
|
|
255
|
+
`/v1/tokens?chainId=${zone}`,
|
|
256
|
+
`/v1/addresses/0x0000000000000000000000000000000000000001/activities?chainId=${zone}`,
|
|
257
|
+
`/v1/transfers?chainId=${zone}&include=token`,
|
|
258
|
+
]) {
|
|
259
|
+
const response = await app.request(path)
|
|
260
|
+
const body = (await response.json()) as { error: { code: string; message: string } }
|
|
261
|
+
|
|
262
|
+
expect(response.status).toBe(403)
|
|
263
|
+
expect(body.error.code).toBe('api_key_forbidden')
|
|
264
|
+
expect(body.error.message).toContain('zone')
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
test('the wildcard scope covers every zone', async () => {
|
|
269
|
+
const app = zoneApp()
|
|
270
|
+
expect((await app.request(`/v1/tokenlist?chainId=${zone}`, as(admin))).status).toBe(200)
|
|
271
|
+
expect((await app.request(`/v1/tokenlist?chainId=${zoneB}`, as(admin))).status).toBe(200)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('a key without the zone scope is refused, naming the scope', async () => {
|
|
275
|
+
const app = zoneApp()
|
|
276
|
+
const response = await app.request(`/v1/transfers?chainId=${zone}`, as(outsider))
|
|
277
|
+
const body = (await response.json()) as { error: { code: string; message: string } }
|
|
278
|
+
|
|
279
|
+
expect(response.status).toBe(403)
|
|
280
|
+
expect(body.error.code).toBe('api_key_forbidden')
|
|
281
|
+
expect(body.error.message).toContain(`zone:${zone}:read`)
|
|
282
|
+
expect(body.error.message).not.toContain('X-Authorization-Token')
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
test('a zone scope does not grant another zone', async () => {
|
|
286
|
+
const app = zoneApp()
|
|
287
|
+
const response = await app.request(`/v1/transfers?chainId=${zoneB}`, as(zoneReader))
|
|
288
|
+
const body = (await response.json()) as { error: { code: string; message: string } }
|
|
289
|
+
|
|
290
|
+
expect(response.status).toBe(403)
|
|
291
|
+
expect(body.error.message).toContain(`zone:${zoneB}:read`)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
test('a caller-signed Zone token cannot authorize REST data', async () => {
|
|
295
|
+
const response = await zoneApp().request(`/v1/tokenlist?chainId=${zone}`, {
|
|
296
|
+
headers: { [ZoneRpcAuthentication.headerName]: zoneToken(zone) },
|
|
297
|
+
})
|
|
298
|
+
expect(response.status).toBe(403)
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
test('an empty Zone token cannot authorize raw RPC', async () => {
|
|
302
|
+
const response = await zoneApp().request(`/rpc/${zone}`, {
|
|
303
|
+
body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }),
|
|
304
|
+
headers: { [ZoneRpcAuthentication.headerName]: '' },
|
|
305
|
+
method: 'POST',
|
|
306
|
+
})
|
|
307
|
+
expect(response.status).toBe(403)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
test('path chain selectors require Zone authorization', async () => {
|
|
311
|
+
const app = TestApp.create({ auth: false, zones: [zoneOptions(zone)] })
|
|
312
|
+
const gecko = await app.request(`/gecko/${zone}/pairs`)
|
|
313
|
+
const block = await app.request(`/chains/${zone}/blocks/by-timestamp?timestamp=1767225600`)
|
|
314
|
+
|
|
315
|
+
expect(gecko.status).toBe(403)
|
|
316
|
+
expect(block.status).toBe(403)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
test('applies Zone authorization under a base path', async () => {
|
|
320
|
+
const app = TestApp.create({ auth: false, path: '/api', zones: [zoneOptions(zone)] })
|
|
321
|
+
|
|
322
|
+
expect((await app.request(`/api/v1/tokenlist?chainId=${zone}`)).status).toBe(403)
|
|
323
|
+
expect((await app.request(`/api/gecko/${zone}/pairs`)).status).toBe(403)
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test('rejects conflicting path and query Zone selectors', async () => {
|
|
327
|
+
const response = await zoneApp().request(
|
|
328
|
+
`/gecko/${zoneB}/pairs?chainId=${zone}`,
|
|
329
|
+
as(zoneReader),
|
|
330
|
+
)
|
|
331
|
+
const body = (await response.json()) as { error: { code: string; message: string } }
|
|
332
|
+
|
|
333
|
+
expect(response.status).toBe(400)
|
|
334
|
+
expect(body.error.code).toBe('chain_id_invalid')
|
|
335
|
+
expect(body.error.message).toBe('Conflicting chain ids')
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
test('rejects conflicting query aliases', async () => {
|
|
339
|
+
const response = await zoneApp().request(
|
|
340
|
+
`/v1/tokenlist?chainId=${zone}&chain_id=${zoneB}`,
|
|
341
|
+
as(admin),
|
|
342
|
+
)
|
|
343
|
+
expect(response.status).toBe(400)
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
test('does not apply the data gate to management routes', async () => {
|
|
347
|
+
const response = await zoneApp().request(`/v1/scopes?chainId=${zone}`)
|
|
348
|
+
expect(response.status).toBe(200)
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
test('Zone responses never seed the anonymous edge cache', async () => {
|
|
352
|
+
const app = zoneApp()
|
|
353
|
+
const path = `/v1/tokenlist?chainId=${zone}`
|
|
354
|
+
expect((await app.request(path, as(zoneReader))).status).toBe(200)
|
|
355
|
+
expect((await app.request(path)).status).toBe(403)
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
test('bypasses stale edge entries after a chain becomes a Zone', async () => {
|
|
359
|
+
const store = Store.memory()
|
|
360
|
+
const path = `/v1/tokenlist?chainId=${zone}`
|
|
361
|
+
const publicApp = TestApp.create({
|
|
362
|
+
auth: false,
|
|
363
|
+
cache: { store },
|
|
364
|
+
supportedChainIds: [zone],
|
|
365
|
+
})
|
|
366
|
+
expect((await publicApp.request(path)).status).toBe(200)
|
|
367
|
+
|
|
368
|
+
const privateApp = TestApp.create({ auth: false, cache: { store }, zones: [zoneOptions(zone)] }) // prettier-ignore
|
|
369
|
+
expect((await privateApp.request(path)).status).toBe(403)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
test('public chains remain edge-cacheable on a Zone deployment', async () => {
|
|
373
|
+
const store = Store.memory()
|
|
374
|
+
const app = TestApp.create({ auth: false, cache: { store }, zones: [zoneOptions(zone)] })
|
|
375
|
+
const path = `/v1/tokenlist?chainId=${Viem.chainId.testnet}`
|
|
376
|
+
|
|
377
|
+
const first = await app.request(path)
|
|
378
|
+
const second = await app.request(path)
|
|
379
|
+
expect(first.status).toBe(200)
|
|
380
|
+
expect(second.status).toBe(200)
|
|
381
|
+
expect(second.headers.get('cache-control')).toContain('public')
|
|
382
|
+
})
|
|
383
|
+
})
|
|
384
|
+
|
|
38
385
|
describe('sandbox chain guard', () => {
|
|
39
386
|
test('rejects a sandbox key that omits `chainId` on a mainnet-default deployment', async () => {
|
|
40
387
|
const response = await mainnetDefaultClient().v1.tokenlist.$get({ query: {} }, as(sandbox))
|
|
@@ -70,3 +417,10 @@ describe('sandbox chain guard', () => {
|
|
|
70
417
|
expect(response.status).toBe(200)
|
|
71
418
|
})
|
|
72
419
|
})
|
|
420
|
+
|
|
421
|
+
async function exhaustPublicQuota(store: Store.Store) {
|
|
422
|
+
const limit = { perMinute: 60 }
|
|
423
|
+
const rateLimit = RateLimit.memory({ store })
|
|
424
|
+
for (let index = 0; index < limit.perMinute; index++)
|
|
425
|
+
await rateLimit.consume({ key: 'public:anonymous', limit })
|
|
426
|
+
}
|