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
@@ -1189,25 +1189,28 @@ describe('narrows responses by status', () => {
1189
1189
  }
1190
1190
  })
1191
1191
 
1192
- test('GET /v1/funding/routes', async () => {
1192
+ test('GET /v1/funding/quotes', async () => {
1193
1193
  const client = Client.create()
1194
- const res = await client.v1.funding.routes.$get({
1194
+ const res = await client.v1.funding.quotes.$get({
1195
1195
  query: {
1196
+ provider: 'across',
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.listFundingRoutes.Response>['data'][number]['sourceAmount']
1209
+ z.output<typeof Funding.schema.quoteFunding.Response>['data'][number]['sourceAmount']
1209
1210
  >()
1210
- expectTypeOf(json.data[0]!.provider.id).toEqualTypeOf<string>()
1211
+ expectTypeOf(json.data[0]!.provider.id).toEqualTypeOf<'across' | 'relay' | 'rhino'>()
1212
+ expectTypeOf(json.data[0]!.quote.expiresAt).toEqualTypeOf<string | undefined>()
1213
+ expectTypeOf(json.data[0]!.quote.outputAmount).toEqualTypeOf<string>()
1211
1214
  void json
1212
1215
  return
1213
1216
  }
@@ -1218,6 +1221,13 @@ describe('narrows responses by status', () => {
1218
1221
  void json
1219
1222
  return
1220
1223
  }
1224
+ case 502: {
1225
+ const json = await res.json()
1226
+ expectTypeOf(json.error.code).toEqualTypeOf<'upstream_error'>()
1227
+ expectTypeOf(json.error.message).toEqualTypeOf<string>()
1228
+ void json
1229
+ return
1230
+ }
1221
1231
  }
1222
1232
  })
1223
1233
 
@@ -1248,24 +1258,24 @@ describe('narrows responses by status', () => {
1248
1258
  }
1249
1259
  })
1250
1260
 
1251
- test('GET /v1/providers', async () => {
1261
+ test('GET /v1/funding/providers', async () => {
1252
1262
  const client = Client.create()
1253
- const res = await client.v1.providers.$get({ query: { capability: 'fundingRoutes' } })
1263
+ const res = await client.v1.funding.providers.$get()
1254
1264
 
1255
- expectTypeOf(res.status).toEqualTypeOf<200 | 400 | PaidAccessStatus>()
1265
+ expectTypeOf(res.status).toEqualTypeOf<200 | PaidAccessStatus>()
1256
1266
 
1257
1267
  switch (res.status) {
1258
1268
  case 200: {
1259
1269
  const json = await res.json()
1260
- expectTypeOf(json.data[0]!.capabilities).toEqualTypeOf<
1261
- z.output<typeof Funding.schema.listProviders.Response>['data'][number]['capabilities']
1270
+ expectTypeOf(json.data[0]!.status).toEqualTypeOf<
1271
+ z.output<typeof Funding.schema.listProviders.Response>['data'][number]['status']
1262
1272
  >()
1263
1273
  void json
1264
1274
  return
1265
1275
  }
1266
1276
  case 400: {
1267
1277
  const json = await res.json()
1268
- expectTypeOf(json.error.code).toEqualTypeOf<Access400<'query_invalid'>>()
1278
+ expectTypeOf(json.error.code).toEqualTypeOf<'api_key_malformed'>()
1269
1279
  expectTypeOf(json.error.message).toEqualTypeOf<string>()
1270
1280
  void json
1271
1281
  return
package/src/Scope.test.ts CHANGED
@@ -12,6 +12,7 @@ describe('extend', () => {
12
12
  ]),
13
13
  ).toEqual([
14
14
  ...Scope.catalog,
15
+ Scope.zoneEntry,
15
16
  {
16
17
  description: 'Read project accounts.',
17
18
  scope: 'accounts:read',
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,32 @@ 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
+ .check(
90
+ z.regex(/^zone:[1-9]\d*:read$/),
91
+ z.describe('Per-zone read scope (`zone:<chainId>:read`).'),
92
+ )
93
+ }
94
+
95
+ /** Per-zone read scope, granting access to one zone chain id. */
96
+ export type Zone = z.output<typeof schema.Zone>
97
+
72
98
  /** Issuable API-key scope identifier, including host-defined scopes. */
73
- export type Id = (typeof catalog)[number]['scope'] | (string & {})
99
+ export type Id = (typeof catalog)[number]['scope'] | Zone | (string & {})
74
100
 
75
101
  /** Scope catalog used by API-key issuance and discovery. */
76
102
  export type Catalog = readonly Entry[]
@@ -92,7 +118,7 @@ export const selfServe: readonly Id[] = catalog
92
118
  */
93
119
  export function extend(entries: readonly Entry[] = []): Catalog {
94
120
  const identifiers = new Set<string>()
95
- const result = [...catalog, ...entries]
121
+ const result = [...catalog, zoneEntry, ...entries]
96
122
  for (const entry of result) {
97
123
  if (!entry.scope.trim()) throw new Error('Scope identifier must not be empty.')
98
124
  if (identifiers.has(entry.scope)) throw new Error(`Duplicate scope \`${entry.scope}\`.`)
@@ -103,10 +129,12 @@ export function extend(entries: readonly Entry[] = []): Catalog {
103
129
 
104
130
  /** Returns whether a catalog contains a scope identifier. */
105
131
  export function includes(catalog: Catalog, scope: string): scope is Id {
106
- return catalog.some((entry) => entry.scope === scope)
132
+ return schema.Zone.safeParse(scope).success || catalog.some((entry) => entry.scope === scope)
107
133
  }
108
134
 
109
135
  /** Returns the self-issuable identifiers in a scope catalog. */
110
136
  export function selfServeFrom(catalog: Catalog): readonly Id[] {
111
- return catalog.filter((entry) => entry.selfServe).map((entry) => entry.scope)
137
+ return catalog
138
+ .filter((entry) => entry.selfServe && !schema.Zone.safeParse(entry.scope).success)
139
+ .map((entry) => entry.scope)
112
140
  }
package/src/Webhooks.ts CHANGED
@@ -28,7 +28,7 @@ export { eventTypes, schema } from './apps/data/routes/webhooks.js'
28
28
  *
29
29
  * The monitoring event types (`log:emitted`, `block:created`,
30
30
  * `transaction:included`) scan a confirmed window directly over JSON-RPC, so the
31
- * poller builds memoized viem clients (via {@link Viem.getClient}); the legacy
31
+ * poller builds memoized viem clients (via {@link Viem.createGetClient}); the legacy
32
32
  * `token:transfer` type still reads from TIDX, so it
33
33
  * also builds memoized TIDX clients (via {@link Tidx.createGetClient}) when a
34
34
  * `tidx` config is supplied. The poller keeps `App.create` a pure Hono app: it
@@ -42,15 +42,10 @@ export function createPoller(options: createPoller.Options): createPoller.Poller
42
42
  const getTidx = options.tidx
43
43
  ? Tidx.createGetClient({ defaultChainId: options.defaultChainId, tidx: options.tidx })
44
44
  : undefined
45
- const clients = new Map<number, Viem.getClient.ReturnType>()
46
- const getClient = (chainId: Viem.ChainId) => {
47
- let client = clients.get(chainId)
48
- if (!client) {
49
- client = Viem.getClient({ chainId, rpc: options.rpc })
50
- clients.set(chainId, client)
51
- }
52
- return client
53
- }
45
+ const getClient = Viem.createGetClient({
46
+ defaultChainId: options.defaultChainId,
47
+ rpc: options.rpc,
48
+ })
54
49
 
55
50
  const metrics = options.metrics ? MetricSink.webhooks(options.metrics) : undefined
56
51
 
@@ -68,6 +68,19 @@ describe('POST /api-keys', () => {
68
68
  expect(resolved?.environment).toBe('sandbox')
69
69
  })
70
70
 
71
+ test('mints a key with a concrete Zone read scope', async () => {
72
+ const { client, store } = TestAdmin.setup()
73
+ const scope = 'zone:421700001:read' as const
74
+ const response = await client['api-keys'].$post({
75
+ json: { ...validBody, scopes: [scope] },
76
+ })
77
+ const body = await TestApp.json(response, ApiKeys.schema.createApiKey.Response)
78
+
79
+ expect(response.status).toBe(200)
80
+ expect(body.scopes).toEqual([scope])
81
+ expect((await core_ApiKeys.resolve(store, body.token))?.scopes).toEqual([scope])
82
+ })
83
+
71
84
  test('records the expiry when provided', async () => {
72
85
  const { client, store } = TestAdmin.setup()
73
86
  const expiresAt = '2100-01-01T00:00:00.000Z'
@@ -91,6 +104,20 @@ describe('POST /api-keys', () => {
91
104
  expect(body.error?.code).toMatchInlineSnapshot(`"body_invalid"`)
92
105
  })
93
106
 
107
+ test('rejects non-concrete or non-canonical Zone scopes with 400', async () => {
108
+ for (const scope of ['zone:<chainId>:read', 'zone:0:read', 'zone:000421700001:read']) {
109
+ const { client } = TestAdmin.setup()
110
+ const response = await client['api-keys'].$post({
111
+ // Cast past the typed client to exercise runtime validation.
112
+ json: { ...validBody, scopes: [scope] } as never,
113
+ })
114
+ const body = (await response.json()) as { error?: { code?: string } }
115
+
116
+ expect(response.status).toBe(400)
117
+ expect(body.error?.code).toBe('body_invalid')
118
+ }
119
+ })
120
+
94
121
  test('ignores a client-supplied createdBy (audit trail is server-set)', async () => {
95
122
  const { client } = TestAdmin.setup()
96
123
  const response = await client['api-keys'].$post({
@@ -19,7 +19,14 @@ export namespace schema {
19
19
  export const Body = Schema.describe(
20
20
  z.extend(z.omit(ApiKeys.schema.MintInput, { createdBy: true }), {
21
21
  scopes: z
22
- .readonly(z.array(z.enum([...Scope.catalog.map((entry) => entry.scope), Scope.wildcard])))
22
+ .readonly(
23
+ z.array(
24
+ z.union([
25
+ z.enum([...Scope.catalog.map((entry) => entry.scope), Scope.wildcard]),
26
+ Scope.schema.Zone,
27
+ ]),
28
+ ),
29
+ )
23
30
  .check(z.describe("Granted scopes. '*' grants all scopes.")),
24
31
  }),
25
32
  'Fields accepted when minting an API key.',
@@ -54,17 +54,22 @@ describe('GET /scopes', () => {
54
54
  "scope": "webhooks:write",
55
55
  "selfServe": false,
56
56
  },
57
+ {
58
+ "description": "Read RPC and indexed data for one zone chain id.",
59
+ "scope": "zone:<chainId>:read",
60
+ "selfServe": false,
61
+ },
57
62
  ],
58
63
  }
59
64
  `)
60
65
  })
61
66
 
62
- test('serves the Scope.catalog source of truth verbatim', async () => {
67
+ test('serves the extended Scope catalog source of truth verbatim', async () => {
63
68
  const { client } = TestAdmin.setup()
64
69
  const response = await client.scopes.$get()
65
70
  const body = await TestApp.json(response, Scopes.schema.getScopes.Response)
66
71
 
67
- expect(body.data).toEqual(Scope.catalog)
72
+ expect(body.data).toEqual(Scope.extend())
68
73
  })
69
74
 
70
75
  test('is never cached (admin responses are per-principal)', async () => {
@@ -33,7 +33,7 @@ export namespace schema {
33
33
  }
34
34
  }
35
35
 
36
- /** Catalog of issuable API-key scopes, served from {@link Scope.catalog}. */
36
+ /** Catalog of API-key scopes, including dynamic scope templates. */
37
37
  export function scopes() {
38
38
  return new Hono<App.Environment>().get(
39
39
  '/',
@@ -45,6 +45,6 @@ export function scopes() {
45
45
  summary: 'List scopes',
46
46
  tags: ['Scopes'],
47
47
  }),
48
- (c) => c.json({ data: Scope.catalog }),
48
+ (c) => c.json({ data: Scope.extend() }),
49
49
  )
50
50
  }
@@ -1,12 +1,18 @@
1
1
  import { Dialog as Base } from '@base-ui/react/dialog'
2
2
  import type { ComponentProps, ReactNode } from 'react'
3
+ import { cx } from 'regen-ui'
3
4
 
4
5
  /** Backdrop + centered popup shell. Children render inside; pair with Title/Description. */
5
- function Popup(props: { children: ReactNode }) {
6
+ function Popup(props: { children: ReactNode; className?: string | undefined }) {
6
7
  return (
7
8
  <Base.Portal>
8
9
  <Base.Backdrop className="fixed inset-0 z-40 bg-black/60 transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0" />
9
- <Base.Popup className="fixed left-1/2 top-1/2 z-50 w-[calc(100vw-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 border border-border bg-surface p-6 shadow-lg outline-none transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0">
10
+ <Base.Popup
11
+ className={cx(
12
+ 'fixed left-1/2 top-1/2 z-50 w-[calc(100vw-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 border border-border bg-surface p-6 shadow-lg outline-none transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0',
13
+ props.className,
14
+ )}
15
+ >
10
16
  {props.children}
11
17
  </Base.Popup>
12
18
  </Base.Portal>
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from 'vite-plus/test'
2
+
3
+ import { fromZoneChainId } from './scopes'
4
+
5
+ describe('fromZoneChainId', () => {
6
+ it('builds a canonical Zone read scope', () => {
7
+ expect(fromZoneChainId(' 000421700001 ')).toBe('zone:421700001:read')
8
+ })
9
+
10
+ it('rejects non-positive and malformed chain ids', () => {
11
+ expect(['', '0', '-1', '1.5', 'mainnet'].map(fromZoneChainId)).toEqual([
12
+ undefined,
13
+ undefined,
14
+ undefined,
15
+ undefined,
16
+ undefined,
17
+ ])
18
+ })
19
+ })
@@ -6,6 +6,18 @@ import { client } from './client'
6
6
  /** A single issuable API-key scope from the catalog endpoint. */
7
7
  export type Scope = InferResponseType<(typeof client)['scopes']['$get'], 200>['data'][number]
8
8
 
9
+ /** Dynamic Zone scope template returned by the scope catalog. */
10
+ export const zoneTemplate = 'zone:<chainId>:read'
11
+
12
+ /** Builds a canonical Zone read scope from a positive integer chain id. */
13
+ export function fromZoneChainId(value: string): `zone:${string}:read` | undefined {
14
+ const chainId = value.trim()
15
+ if (!/^\d+$/.test(chainId)) return undefined
16
+ const normalized = BigInt(chainId)
17
+ if (normalized === 0n) return undefined
18
+ return `zone:${normalized}:read`
19
+ }
20
+
9
21
  /** List the issuable API-key scope catalog. */
10
22
  export namespace get {
11
23
  export const key = ['scopes'] as const
@@ -36,7 +36,18 @@ function RouteComponent() {
36
36
  const rateLimit = field('rateLimit')
37
37
  const perMinute = rateLimit ? Number(rateLimit) : undefined
38
38
  const environment = field('environment') === 'sandbox' ? 'sandbox' : 'production'
39
- const scopes = form.getAll('scopes').filter((v): v is string => typeof v === 'string')
39
+ const zoneChainId = field('zoneChainId')
40
+ const pendingZoneScope = zoneChainId ? Scopes.fromZoneChainId(zoneChainId) : undefined
41
+ if (zoneChainId && !pendingZoneScope) {
42
+ alert('Enter a positive integer Zone chain ID.')
43
+ return
44
+ }
45
+ const scopes = [
46
+ ...new Set([
47
+ ...form.getAll('scopes').filter((v): v is string => typeof v === 'string'),
48
+ ...(pendingZoneScope ? [pendingZoneScope] : []),
49
+ ]),
50
+ ]
40
51
  if (scopes.length === 0) {
41
52
  alert('Select at least one scope.')
42
53
  return
@@ -98,7 +109,7 @@ function RouteComponent() {
98
109
  )}
99
110
  loading={keys.isPending}
100
111
  />
101
- <Dialog.Popup>
112
+ <Dialog.Popup className="flex max-h-[calc(100dvh-2rem)] flex-col overflow-hidden">
102
113
  <ProvisionForm
103
114
  onSubmit={onSubmit}
104
115
  orgId={filters.orgId}
@@ -169,7 +180,7 @@ function ProvisionForm(props: {
169
180
  : 'Create a key that authenticates requests to the API.'}
170
181
  </Dialog.Description>
171
182
  <form
172
- className="mt-6 flex flex-col gap-8"
183
+ className="mt-6 flex min-h-0 flex-1 flex-col gap-8"
173
184
  onSubmit={(event) => {
174
185
  event.preventDefault()
175
186
  props.onSubmit(new FormData(event.currentTarget))
@@ -190,7 +201,10 @@ function ProvisionForm(props: {
190
201
  ) : null}
191
202
  </div>
192
203
  ) : null}
193
- <Fields className="gap-6" variant="default">
204
+ <Fields
205
+ className="min-h-0 flex-1 gap-6 [grid-template-rows:auto_auto_auto_minmax(0,1fr)]"
206
+ variant="default"
207
+ >
194
208
  <Input label="Name" name="name" placeholder="e.g. production-api" />
195
209
  <div className="flex flex-col gap-2">
196
210
  <span className="label-13 text-foreground-secondary">Environment</span>
@@ -212,35 +226,41 @@ function ProvisionForm(props: {
212
226
  suffix="req/min"
213
227
  type="number"
214
228
  />
215
- <div className="flex flex-col gap-2">
229
+ <div className="flex min-h-0 flex-col gap-2 overflow-hidden">
216
230
  <span className="label-13 text-foreground-secondary">Scopes</span>
217
231
  {scopes.isPending ? (
218
232
  <span className="text-label-13 text-foreground-secondary">Loading scopes…</span>
219
233
  ) : scopes.error ? (
220
234
  <span className="text-label-13 text-negative">{scopes.error.message}</span>
221
235
  ) : (
222
- <Rows className="border border-border" variant="surface">
223
- {(scopes.data?.data ?? []).map((entry) => (
224
- <Rows.Row
225
- key={entry.scope}
226
- className="min-h-[56px] py-3"
227
- render={<label className="cursor-pointer" />}
228
- style={{ height: 'auto' }}
229
- >
230
- <Checkbox
231
- defaultChecked={entry.scope === 'data:read'}
232
- name="scopes"
233
- value={entry.scope}
234
- />
235
- <span className="flex min-w-0 flex-col gap-0.5">
236
- <span className="text-label-13 text-foreground">{entry.scope}</span>
237
- <span className="text-label-12 text-foreground-secondary">
238
- {entry.description}
239
- </span>
240
- </span>
241
- </Rows.Row>
242
- ))}
243
- </Rows>
236
+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain border border-border">
237
+ <Rows variant="surface">
238
+ {(scopes.data?.data ?? []).map((entry) =>
239
+ entry.scope === Scopes.zoneTemplate ? (
240
+ <ZoneScopeRow description={entry.description} key={entry.scope} />
241
+ ) : (
242
+ <Rows.Row
243
+ key={entry.scope}
244
+ className="min-h-[56px] py-3"
245
+ render={<label className="cursor-pointer" />}
246
+ style={{ height: 'auto' }}
247
+ >
248
+ <Checkbox
249
+ defaultChecked={entry.scope === 'data:read'}
250
+ name="scopes"
251
+ value={entry.scope}
252
+ />
253
+ <span className="flex min-w-0 flex-col gap-0.5">
254
+ <span className="text-label-13 text-foreground">{entry.scope}</span>
255
+ <span className="text-label-12 text-foreground-secondary">
256
+ {entry.description}
257
+ </span>
258
+ </span>
259
+ </Rows.Row>
260
+ ),
261
+ )}
262
+ </Rows>
263
+ </div>
244
264
  )}
245
265
  </div>
246
266
  </Fields>
@@ -255,6 +275,87 @@ function ProvisionForm(props: {
255
275
  )
256
276
  }
257
277
 
278
+ function ZoneScopeRow(props: { description: string }) {
279
+ const [chainId, setChainId] = useState('')
280
+ const [selected, setSelected] = useState<readonly string[]>([])
281
+ const scope = Scopes.fromZoneChainId(chainId)
282
+ const duplicate = scope ? selected.includes(scope) : false
283
+ const error =
284
+ chainId && !scope
285
+ ? 'Enter a positive integer chain ID.'
286
+ : duplicate
287
+ ? 'Zone already added.'
288
+ : false
289
+
290
+ function add() {
291
+ if (!scope || duplicate) return
292
+ setSelected((current) => [...current, scope])
293
+ setChainId('')
294
+ }
295
+
296
+ return (
297
+ <Rows.Row className="min-h-[56px] flex-col items-stretch py-3" style={{ height: 'auto' }}>
298
+ <span className="flex min-w-0 flex-col gap-0.5">
299
+ <span className="text-label-13 text-foreground">{Scopes.zoneTemplate}</span>
300
+ <span className="text-label-12 text-foreground-secondary">{props.description}</span>
301
+ </span>
302
+ <div className="flex items-start gap-2 max-sm:flex-col">
303
+ <div className="min-w-0 flex-1 max-sm:w-full">
304
+ <Input
305
+ aria-label="Zone chain ID"
306
+ autoComplete="off"
307
+ error={error}
308
+ inputMode="numeric"
309
+ name="zoneChainId"
310
+ onChange={(event) => setChainId(event.target.value)}
311
+ onKeyDown={(event) => {
312
+ if (event.key !== 'Enter') return
313
+ event.preventDefault()
314
+ add()
315
+ }}
316
+ pattern="[0-9]*"
317
+ placeholder="421700001"
318
+ prefix="zone:"
319
+ suffix=":read"
320
+ value={chainId}
321
+ />
322
+ </div>
323
+ <Button
324
+ className="max-sm:w-full"
325
+ disabled={!scope || duplicate}
326
+ onClick={add}
327
+ type="button"
328
+ >
329
+ Add zone
330
+ </Button>
331
+ </div>
332
+ {selected.length > 0 ? (
333
+ <ul className="flex flex-col gap-2">
334
+ {selected.map((scope) => (
335
+ <li
336
+ className="flex min-w-0 items-center gap-2 border border-border bg-secondary py-1 pr-1 pl-2"
337
+ key={scope}
338
+ >
339
+ <input name="scopes" type="hidden" value={scope} />
340
+ <code className="min-w-0 flex-1 truncate text-label-12 text-foreground">{scope}</code>
341
+ <Button.Icon
342
+ onClick={() =>
343
+ setSelected((current) => current.filter((candidate) => candidate !== scope))
344
+ }
345
+ size="md"
346
+ title={`Remove ${scope}`}
347
+ variant="plain"
348
+ >
349
+ <X className="size-4" />
350
+ </Button.Icon>
351
+ </li>
352
+ ))}
353
+ </ul>
354
+ ) : null}
355
+ </Rows.Row>
356
+ )
357
+ }
358
+
258
359
  function KeyList(props: {
259
360
  environment: 'production' | 'sandbox'
260
361
  error: Error | null