tapimo 0.4.4 → 0.5.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.
Files changed (229) hide show
  1. package/dist/App.d.ts +4701 -3416
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js +24 -2
  4. package/dist/App.js.map +1 -1
  5. package/dist/Assets.js +3 -3
  6. package/dist/Assets.js.map +1 -1
  7. package/dist/Scope.d.ts +18 -1
  8. package/dist/Scope.d.ts.map +1 -1
  9. package/dist/Scope.js +23 -3
  10. package/dist/Scope.js.map +1 -1
  11. package/dist/Webhooks.d.ts +1 -1
  12. package/dist/Webhooks.d.ts.map +1 -1
  13. package/dist/Webhooks.js +5 -10
  14. package/dist/Webhooks.js.map +1 -1
  15. package/dist/admin/App.d.ts +6 -34
  16. package/dist/admin/App.d.ts.map +1 -1
  17. package/dist/admin/apps/api-keys.d.ts +3 -3
  18. package/dist/admin/apps/api-keys.d.ts.map +1 -1
  19. package/dist/admin/apps/api-keys.js +4 -1
  20. package/dist/admin/apps/api-keys.js.map +1 -1
  21. package/dist/admin/apps/scopes.d.ts +6 -34
  22. package/dist/admin/apps/scopes.d.ts.map +1 -1
  23. package/dist/admin/apps/scopes.js +2 -2
  24. package/dist/admin/apps/scopes.js.map +1 -1
  25. package/dist/admin/apps/verified-tokens.d.ts +57 -57
  26. package/dist/admin/ui.generated.d.ts +1 -1
  27. package/dist/admin/ui.generated.js +33 -33
  28. package/dist/admin/ui.generated.js.map +1 -1
  29. package/dist/apps/data/App.d.ts +4574 -4277
  30. package/dist/apps/data/App.d.ts.map +1 -1
  31. package/dist/apps/data/App.js +155 -77
  32. package/dist/apps/data/App.js.map +1 -1
  33. package/dist/apps/data/FundingQuoteProviders.d.ts +109 -0
  34. package/dist/apps/data/FundingQuoteProviders.d.ts.map +1 -0
  35. package/dist/apps/data/FundingQuoteProviders.js +464 -0
  36. package/dist/apps/data/FundingQuoteProviders.js.map +1 -0
  37. package/dist/apps/data/index.d.ts +1 -1
  38. package/dist/apps/data/index.js +1 -1
  39. package/dist/apps/data/routes/activities.d.ts +26 -26
  40. package/dist/apps/data/routes/addresses.d.ts +38 -38
  41. package/dist/apps/data/routes/balances.d.ts +45 -45
  42. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  43. package/dist/apps/data/routes/balances.js +67 -149
  44. package/dist/apps/data/routes/balances.js.map +1 -1
  45. package/dist/apps/data/routes/blocks.d.ts +2 -3
  46. package/dist/apps/data/routes/blocks.d.ts.map +1 -1
  47. package/dist/apps/data/routes/blocks.js +3 -5
  48. package/dist/apps/data/routes/blocks.js.map +1 -1
  49. package/dist/apps/data/routes/coingecko.d.ts +177 -176
  50. package/dist/apps/data/routes/coingecko.d.ts.map +1 -1
  51. package/dist/apps/data/routes/coingecko.js +3 -8
  52. package/dist/apps/data/routes/coingecko.js.map +1 -1
  53. package/dist/apps/data/routes/funding.d.ts +131 -228
  54. package/dist/apps/data/routes/funding.d.ts.map +1 -1
  55. package/dist/apps/data/routes/funding.js +152 -219
  56. package/dist/apps/data/routes/funding.js.map +1 -1
  57. package/dist/apps/data/routes/indexer.d.ts +34 -34
  58. package/dist/apps/data/routes/indexer.d.ts.map +1 -1
  59. package/dist/apps/data/routes/indexer.js +9 -2
  60. package/dist/apps/data/routes/indexer.js.map +1 -1
  61. package/dist/apps/data/routes/receipts.d.ts +228 -228
  62. package/dist/apps/data/routes/rpc.d.ts.map +1 -1
  63. package/dist/apps/data/routes/rpc.js +86 -16
  64. package/dist/apps/data/routes/rpc.js.map +1 -1
  65. package/dist/apps/data/routes/tokenlist.d.ts +12 -12
  66. package/dist/apps/data/routes/verified-tokens.d.ts +5 -5
  67. package/dist/apps/data/routes/webhooks.d.ts +340 -0
  68. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  69. package/dist/apps/data/routes/webhooks.js +13 -1
  70. package/dist/apps/data/routes/webhooks.js.map +1 -1
  71. package/dist/apps/index.d.ts +1 -1
  72. package/dist/apps/index.js +1 -1
  73. package/dist/apps/management/App.d.ts +1833 -851
  74. package/dist/apps/management/App.d.ts.map +1 -1
  75. package/dist/apps/management/App.js +4 -0
  76. package/dist/apps/management/App.js.map +1 -1
  77. package/dist/apps/management/routes/api-keys.d.ts +41 -41
  78. package/dist/apps/management/routes/api-keys.js +2 -2
  79. package/dist/apps/management/routes/api-keys.js.map +1 -1
  80. package/dist/apps/management/routes/billing.d.ts +48 -48
  81. package/dist/apps/management/routes/invitations.d.ts +132 -132
  82. package/dist/apps/management/routes/invite-links.d.ts +1100 -0
  83. package/dist/apps/management/routes/invite-links.d.ts.map +1 -0
  84. package/dist/apps/management/routes/invite-links.js +411 -0
  85. package/dist/apps/management/routes/invite-links.js.map +1 -0
  86. package/dist/apps/management/routes/members.d.ts +41 -41
  87. package/dist/apps/management/routes/orgs.d.ts +92 -92
  88. package/dist/apps/management/routes/projects.d.ts +9 -9
  89. package/dist/apps/management/routes/scopes.d.ts.map +1 -1
  90. package/dist/apps/management/routes/scopes.js +2 -2
  91. package/dist/apps/management/routes/scopes.js.map +1 -1
  92. package/dist/apps/management/routes/usage.d.ts +24 -24
  93. package/dist/apps/mcp.js +2 -2
  94. package/dist/apps/mcp.js.map +1 -1
  95. package/dist/apps/relay/App.d.ts +2 -2
  96. package/dist/apps/relay/App.d.ts.map +1 -1
  97. package/dist/apps/relay/App.js +44 -6
  98. package/dist/apps/relay/App.js.map +1 -1
  99. package/dist/db/Db.d.ts +3 -0
  100. package/dist/db/Db.d.ts.map +1 -1
  101. package/dist/db/Db.js +57 -0
  102. package/dist/db/Db.js.map +1 -1
  103. package/dist/db/Schema.d.ts +31 -0
  104. package/dist/db/Schema.d.ts.map +1 -1
  105. package/dist/db/Schema.js +31 -0
  106. package/dist/db/Schema.js.map +1 -1
  107. package/dist/db/tables/inviteLinks.d.ts +42 -0
  108. package/dist/db/tables/inviteLinks.d.ts.map +1 -0
  109. package/dist/db/tables/inviteLinks.js +148 -0
  110. package/dist/db/tables/inviteLinks.js.map +1 -0
  111. package/dist/db/tables/organizations.d.ts.map +1 -1
  112. package/dist/db/tables/organizations.js +2 -0
  113. package/dist/db/tables/organizations.js.map +1 -1
  114. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  115. package/dist/db/tables/sponsoredTransactions.js +6 -2
  116. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  117. package/dist/handlers/relay.d.ts.map +1 -1
  118. package/dist/handlers/relay.js +13 -3
  119. package/dist/handlers/relay.js.map +1 -1
  120. package/dist/internal/Auth.d.ts +9 -1
  121. package/dist/internal/Auth.d.ts.map +1 -1
  122. package/dist/internal/Auth.js +138 -10
  123. package/dist/internal/Auth.js.map +1 -1
  124. package/dist/internal/EdgeCache.d.ts +3 -1
  125. package/dist/internal/EdgeCache.d.ts.map +1 -1
  126. package/dist/internal/EdgeCache.js +17 -2
  127. package/dist/internal/EdgeCache.js.map +1 -1
  128. package/dist/internal/{FundingRoutes.d.ts → FundingQuotes.d.ts} +240 -369
  129. package/dist/internal/FundingQuotes.d.ts.map +1 -0
  130. package/dist/internal/{FundingRoutes.js → FundingQuotes.js} +144 -183
  131. package/dist/internal/FundingQuotes.js.map +1 -0
  132. package/dist/internal/Log.d.ts +18 -12
  133. package/dist/internal/Log.d.ts.map +1 -1
  134. package/dist/internal/Log.js +2 -0
  135. package/dist/internal/Log.js.map +1 -1
  136. package/dist/internal/Tidx.d.ts +9 -3
  137. package/dist/internal/Tidx.d.ts.map +1 -1
  138. package/dist/internal/Tidx.js +13 -7
  139. package/dist/internal/Tidx.js.map +1 -1
  140. package/dist/internal/Viem.d.ts +17 -2
  141. package/dist/internal/Viem.d.ts.map +1 -1
  142. package/dist/internal/Viem.js +30 -3
  143. package/dist/internal/Viem.js.map +1 -1
  144. package/dist/internal/index.d.ts +1 -1
  145. package/dist/internal/index.js +1 -1
  146. package/package.json +2 -2
  147. package/src/ApiKeys.test.ts +25 -0
  148. package/src/App.test-d.ts +2 -0
  149. package/src/App.test.ts +13 -2
  150. package/src/App.ts +37 -5
  151. package/src/Assets.ts +3 -3
  152. package/src/Client.test-d.ts +21 -11
  153. package/src/Scope.test.ts +1 -0
  154. package/src/Scope.ts +32 -4
  155. package/src/Webhooks.ts +5 -10
  156. package/src/admin/apps/api-keys.test.ts +27 -0
  157. package/src/admin/apps/api-keys.ts +8 -1
  158. package/src/admin/apps/scopes.test.ts +7 -2
  159. package/src/admin/apps/scopes.ts +2 -2
  160. package/src/admin/ui/src/components/Dialog.tsx +8 -2
  161. package/src/admin/ui/src/lib/scopes.test.ts +19 -0
  162. package/src/admin/ui/src/lib/scopes.ts +12 -0
  163. package/src/admin/ui/src/routes/api-keys.tsx +128 -27
  164. package/src/admin/ui.generated.ts +33 -33
  165. package/src/apps/composition.test.ts +77 -0
  166. package/src/apps/data/App.test.ts +354 -0
  167. package/src/apps/data/App.ts +178 -91
  168. package/src/apps/data/FundingQuoteProviders.test.ts +577 -0
  169. package/src/apps/data/FundingQuoteProviders.ts +633 -0
  170. package/src/apps/data/index.ts +1 -1
  171. package/src/apps/data/routes/balances.test.ts +64 -18
  172. package/src/apps/data/routes/balances.ts +76 -177
  173. package/src/apps/data/routes/blocks.ts +3 -5
  174. package/src/apps/data/routes/coingecko.ts +3 -8
  175. package/src/apps/data/routes/funding.test.ts +304 -192
  176. package/src/apps/data/routes/funding.ts +166 -286
  177. package/src/apps/data/routes/indexer.test.ts +173 -15
  178. package/src/apps/data/routes/indexer.ts +8 -2
  179. package/src/apps/data/routes/rpc.test.ts +219 -0
  180. package/src/apps/data/routes/rpc.ts +92 -17
  181. package/src/apps/data/routes/webhooks.test.ts +21 -0
  182. package/src/apps/data/routes/webhooks.ts +13 -1
  183. package/src/apps/index.ts +1 -1
  184. package/src/apps/management/App.ts +4 -0
  185. package/src/apps/management/Billing.test.ts +2 -2
  186. package/src/apps/management/routes/api-keys.test.ts +47 -3
  187. package/src/apps/management/routes/api-keys.ts +2 -2
  188. package/src/apps/management/routes/invite-links.test.ts +385 -0
  189. package/src/apps/management/routes/invite-links.ts +570 -0
  190. package/src/apps/management/routes/scopes.test.ts +3 -2
  191. package/src/apps/management/routes/scopes.ts +4 -2
  192. package/src/apps/mcp.test.ts +18 -0
  193. package/src/apps/mcp.ts +2 -2
  194. package/src/apps/relay/App.test.ts +328 -7
  195. package/src/apps/relay/App.ts +42 -6
  196. package/src/apps/relay/Sponsorships.test.ts +2 -2
  197. package/src/db/Db.test.ts +5 -5
  198. package/src/db/Db.ts +65 -0
  199. package/src/db/Schema.ts +39 -0
  200. package/src/db/tables/billingSettings.test.ts +2 -2
  201. package/src/db/tables/inviteLinks.ts +187 -0
  202. package/src/db/tables/organizations.test.ts +2 -2
  203. package/src/db/tables/organizations.ts +2 -0
  204. package/src/db/tables/sponsoredTransactions.test.ts +19 -2
  205. package/src/db/tables/sponsoredTransactions.ts +21 -14
  206. package/src/db/tables/stripeCustomers.test.ts +2 -2
  207. package/src/handlers/relay.ts +16 -4
  208. package/src/internal/Auth.test.ts +132 -22
  209. package/src/internal/Auth.ts +181 -14
  210. package/src/internal/EdgeCache.test.ts +24 -3
  211. package/src/internal/EdgeCache.ts +18 -2
  212. package/src/internal/FundingQuotes.test.ts +268 -0
  213. package/src/internal/{FundingRoutes.ts → FundingQuotes.ts} +216 -277
  214. package/src/internal/Log.ts +21 -14
  215. package/src/internal/Tidx.test.ts +48 -0
  216. package/src/internal/Tidx.ts +21 -7
  217. package/src/internal/Viem.test.ts +46 -0
  218. package/src/internal/Viem.ts +48 -6
  219. package/src/internal/index.test.ts +1 -1
  220. package/src/internal/index.ts +1 -1
  221. package/dist/apps/data/FundingRouteProviders.d.ts +0 -36
  222. package/dist/apps/data/FundingRouteProviders.d.ts.map +0 -1
  223. package/dist/apps/data/FundingRouteProviders.js +0 -245
  224. package/dist/apps/data/FundingRouteProviders.js.map +0 -1
  225. package/dist/internal/FundingRoutes.d.ts.map +0 -1
  226. package/dist/internal/FundingRoutes.js.map +0 -1
  227. package/src/apps/data/FundingRouteProviders.test.ts +0 -225
  228. package/src/apps/data/FundingRouteProviders.ts +0 -312
  229. package/src/internal/FundingRoutes.test.ts +0 -213
@@ -25,6 +25,46 @@ async function paths(app: { request: (path: string) => Promise<Response> | Respo
25
25
  return Object.keys((await document(app)).paths)
26
26
  }
27
27
 
28
+ describe('App.create()', () => {
29
+ test('publishes shared chain context without data routes', async () => {
30
+ const rpc = { url: () => 'https://rpc.example' }
31
+ const group = new Hono<App.Environment>().get('/context', (c) => {
32
+ const client = c.get('getClient')()
33
+ return c.json({
34
+ chainId: c.get('chainId'),
35
+ clientChainId: client.chain.id,
36
+ memoized: client === c.get('getClient')(),
37
+ rpc: c.get('rpc') === rpc,
38
+ supportedChainIds: [...c.get('supportedChainIds')].sort((a, b) => a - b),
39
+ })
40
+ })
41
+ const app = App.create({
42
+ auth: false,
43
+ db: TestApp.database(),
44
+ defaultChainId: 1337,
45
+ rpc,
46
+ supportedChainIds: [999],
47
+ }).route('/', group)
48
+
49
+ const response = await app.request('/context')
50
+
51
+ expect(await response.json()).toMatchInlineSnapshot(`
52
+ {
53
+ "chainId": 1337,
54
+ "clientChainId": 1337,
55
+ "memoized": true,
56
+ "rpc": true,
57
+ "supportedChainIds": [
58
+ 999,
59
+ 1337,
60
+ 4217,
61
+ 42431,
62
+ ],
63
+ }
64
+ `)
65
+ })
66
+ })
67
+
28
68
  describe('App.from()', () => {
29
69
  test('contributes external route-group metadata', async () => {
30
70
  const group = new Hono().get(
@@ -110,6 +150,43 @@ describe('data()', () => {
110
150
  const orgs = await app.request('/v1/orgs')
111
151
  expect(orgs.status).toBe(404)
112
152
  })
153
+
154
+ test('inherits shared chain context', async () => {
155
+ const rpc = { url: () => 'https://rpc.example' }
156
+ const context = new Hono<App.Environment>().get('/context', (c) =>
157
+ c.json({
158
+ chainId: c.get('chainId'),
159
+ clientChainId: c.get('getClient')().chain.id,
160
+ rpc: c.get('rpc') === rpc,
161
+ supportedChainIds: [...c.get('supportedChainIds')].sort((a, b) => a - b),
162
+ }),
163
+ )
164
+ const app = App.create({
165
+ auth: false,
166
+ db: TestApp.database(),
167
+ defaultChainId: 333,
168
+ rpc,
169
+ supportedChainIds: [444],
170
+ })
171
+ .route('/', data())
172
+ .route('/', context)
173
+
174
+ const response = await app.request('/context')
175
+
176
+ expect(await response.json()).toMatchInlineSnapshot(`
177
+ {
178
+ "chainId": 333,
179
+ "clientChainId": 333,
180
+ "rpc": true,
181
+ "supportedChainIds": [
182
+ 333,
183
+ 444,
184
+ 4217,
185
+ 42431,
186
+ ],
187
+ }
188
+ `)
189
+ })
113
190
  })
114
191
 
115
192
  describe('relay()', () => {
@@ -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
+ }