tapimo 0.4.4 → 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 +7403 -6127
- 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 +115 -115
- package/dist/apps/data/App.d.ts +1838 -1534
- 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/{FundingRouteProviders.d.ts → FundingQuoteProviders.d.ts} +7 -7
- package/dist/apps/data/{FundingRouteProviders.d.ts.map → FundingQuoteProviders.d.ts.map} +1 -1
- package/dist/apps/data/{FundingRouteProviders.js → FundingQuoteProviders.js} +36 -91
- 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/funding.d.ts +207 -388
- package/dist/apps/data/routes/funding.d.ts.map +1 -1
- package/dist/apps/data/routes/funding.js +143 -221
- 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 +33 -33
- package/dist/apps/data/routes/transactions.d.ts +281 -281
- package/dist/apps/data/routes/transfers.d.ts +58 -58
- package/dist/apps/data/routes/verified-tokens.d.ts +86 -86
- 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 +1090 -108
- 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 +118 -118
- 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 +207 -207
- 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 +17 -17
- package/dist/apps/management/routes/members.d.ts +71 -71
- package/dist/apps/management/routes/orgs.d.ts +12 -12
- package/dist/apps/management/routes/projects.d.ts +70 -70
- 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 +75 -75
- 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/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 +9 -1
- package/dist/internal/Auth.d.ts.map +1 -1
- package/dist/internal/Auth.js +130 -10
- 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/{FundingRoutes.d.ts → FundingQuotes.d.ts} +185 -428
- package/dist/internal/FundingQuotes.d.ts.map +1 -0
- package/dist/internal/{FundingRoutes.js → FundingQuotes.js} +93 -218
- package/dist/internal/FundingQuotes.js.map +1 -0
- package/dist/internal/Viem.d.ts +3 -1
- 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 +1 -1
- package/dist/internal/index.js +1 -1
- package/package.json +1 -1
- package/src/ApiKeys.test.ts +23 -0
- package/src/App.test-d.ts +2 -0
- package/src/App.ts +6 -0
- package/src/Client.test-d.ts +20 -11
- 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/{FundingRouteProviders.ts → FundingQuoteProviders.ts} +40 -101
- 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 +151 -206
- package/src/apps/data/routes/funding.ts +156 -291
- package/src/apps/data/routes/indexer.test.ts +97 -1
- 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 +21 -0
- 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/relay/App.test.ts +324 -4
- package/src/apps/relay/App.ts +40 -4
- 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 +132 -22
- package/src/internal/Auth.ts +171 -14
- 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/{FundingRoutes.ts → FundingQuotes.ts} +153 -312
- package/src/internal/Viem.ts +14 -5
- package/src/internal/index.test.ts +1 -1
- package/src/internal/index.ts +1 -1
- package/dist/apps/data/FundingRouteProviders.js.map +0 -1
- 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 -225
- package/src/internal/FundingRoutes.test.ts +0 -213
package/src/ApiKeys.test.ts
CHANGED
|
@@ -97,6 +97,29 @@ describe('mint', () => {
|
|
|
97
97
|
ApiKeys.mint(Store.memory(), { orgId: 'org_a', scopes: ['totally:made-up' as never] }),
|
|
98
98
|
).rejects.toThrowError(/invalid API key input/)
|
|
99
99
|
})
|
|
100
|
+
|
|
101
|
+
test('mints a per-zone read scope', async () => {
|
|
102
|
+
const state = Store.memory()
|
|
103
|
+
const { token } = await ApiKeys.mint(state, {
|
|
104
|
+
orgId: 'org_a',
|
|
105
|
+
scopes: ['zone:421700001:read'],
|
|
106
|
+
})
|
|
107
|
+
const resolved = await ApiKeys.resolve(state, token)
|
|
108
|
+
expect(resolved?.scopes).toEqual(['zone:421700001:read'])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('rejects malformed zone scopes', async () => {
|
|
112
|
+
for (const scope of [
|
|
113
|
+
'zone::read',
|
|
114
|
+
'zone:abc:read',
|
|
115
|
+
'zone:1.5:read',
|
|
116
|
+
'zone:-1:read',
|
|
117
|
+
'zone:1:write',
|
|
118
|
+
])
|
|
119
|
+
await expect(
|
|
120
|
+
ApiKeys.mint(Store.memory(), { orgId: 'org_a', scopes: [scope as never] }),
|
|
121
|
+
).rejects.toThrowError(/invalid API key input/)
|
|
122
|
+
})
|
|
100
123
|
})
|
|
101
124
|
|
|
102
125
|
describe('list', () => {
|
package/src/App.test-d.ts
CHANGED
|
@@ -17,7 +17,9 @@ describe('App.Endpoint', () => {
|
|
|
17
17
|
|
|
18
18
|
expectTypeOf<'GET /v1/bogus'>().not.toExtend<App.Endpoint>()
|
|
19
19
|
// The mounted routes are GET-only; other verbs must not appear.
|
|
20
|
+
expectTypeOf<'POST /v1/indexer/query'>().not.toExtend<App.Endpoint>()
|
|
20
21
|
expectTypeOf<'POST /v1/tokens'>().not.toExtend<App.Endpoint>()
|
|
22
|
+
expectTypeOf<'POST /v1/tokens/:token'>().not.toExtend<App.Endpoint>()
|
|
21
23
|
expectTypeOf<'DELETE /v1/tokens/:token'>().not.toExtend<App.Endpoint>()
|
|
22
24
|
})
|
|
23
25
|
|
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,6 +1220,13 @@ describe('narrows responses by status', () => {
|
|
|
1218
1220
|
void json
|
|
1219
1221
|
return
|
|
1220
1222
|
}
|
|
1223
|
+
case 502: {
|
|
1224
|
+
const json = await res.json()
|
|
1225
|
+
expectTypeOf(json.error.code).toEqualTypeOf<'upstream_error'>()
|
|
1226
|
+
expectTypeOf(json.error.message).toEqualTypeOf<string>()
|
|
1227
|
+
void json
|
|
1228
|
+
return
|
|
1229
|
+
}
|
|
1221
1230
|
}
|
|
1222
1231
|
})
|
|
1223
1232
|
|
|
@@ -1248,24 +1257,24 @@ describe('narrows responses by status', () => {
|
|
|
1248
1257
|
}
|
|
1249
1258
|
})
|
|
1250
1259
|
|
|
1251
|
-
test('GET /v1/providers', async () => {
|
|
1260
|
+
test('GET /v1/funding/providers', async () => {
|
|
1252
1261
|
const client = Client.create()
|
|
1253
|
-
const res = await client.v1.providers.$get(
|
|
1262
|
+
const res = await client.v1.funding.providers.$get()
|
|
1254
1263
|
|
|
1255
|
-
expectTypeOf(res.status).toEqualTypeOf<200 |
|
|
1264
|
+
expectTypeOf(res.status).toEqualTypeOf<200 | PaidAccessStatus>()
|
|
1256
1265
|
|
|
1257
1266
|
switch (res.status) {
|
|
1258
1267
|
case 200: {
|
|
1259
1268
|
const json = await res.json()
|
|
1260
|
-
expectTypeOf(json.data[0]!.
|
|
1261
|
-
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']
|
|
1262
1271
|
>()
|
|
1263
1272
|
void json
|
|
1264
1273
|
return
|
|
1265
1274
|
}
|
|
1266
1275
|
case 400: {
|
|
1267
1276
|
const json = await res.json()
|
|
1268
|
-
expectTypeOf(json.error.code).toEqualTypeOf<
|
|
1277
|
+
expectTypeOf(json.error.code).toEqualTypeOf<'api_key_malformed'>()
|
|
1269
1278
|
expectTypeOf(json.error.message).toEqualTypeOf<string>()
|
|
1270
1279
|
void json
|
|
1271
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
|
+
}
|