tapimo 0.5.0 → 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 (135) hide show
  1. package/dist/App.d.ts +4879 -4870
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js +20 -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.map +1 -1
  8. package/dist/Scope.js +1 -2
  9. package/dist/Scope.js.map +1 -1
  10. package/dist/Webhooks.d.ts +1 -1
  11. package/dist/Webhooks.d.ts.map +1 -1
  12. package/dist/Webhooks.js +5 -10
  13. package/dist/Webhooks.js.map +1 -1
  14. package/dist/admin/App.d.ts +6 -34
  15. package/dist/admin/App.d.ts.map +1 -1
  16. package/dist/admin/apps/api-keys.d.ts +3 -3
  17. package/dist/admin/apps/api-keys.d.ts.map +1 -1
  18. package/dist/admin/apps/api-keys.js +4 -1
  19. package/dist/admin/apps/api-keys.js.map +1 -1
  20. package/dist/admin/apps/scopes.d.ts +6 -34
  21. package/dist/admin/apps/scopes.d.ts.map +1 -1
  22. package/dist/admin/apps/scopes.js +2 -2
  23. package/dist/admin/apps/scopes.js.map +1 -1
  24. package/dist/admin/apps/verified-tokens.d.ts +116 -116
  25. package/dist/admin/ui.generated.d.ts +1 -1
  26. package/dist/admin/ui.generated.js +33 -33
  27. package/dist/admin/ui.generated.js.map +1 -1
  28. package/dist/apps/data/App.d.ts +3199 -3206
  29. package/dist/apps/data/App.d.ts.map +1 -1
  30. package/dist/apps/data/App.js +25 -38
  31. package/dist/apps/data/App.js.map +1 -1
  32. package/dist/apps/data/FundingQuoteProviders.d.ts +76 -3
  33. package/dist/apps/data/FundingQuoteProviders.d.ts.map +1 -1
  34. package/dist/apps/data/FundingQuoteProviders.js +276 -2
  35. package/dist/apps/data/FundingQuoteProviders.js.map +1 -1
  36. package/dist/apps/data/routes/activities.d.ts +245 -245
  37. package/dist/apps/data/routes/addresses.d.ts +206 -206
  38. package/dist/apps/data/routes/balances.d.ts +46 -46
  39. package/dist/apps/data/routes/blocks.d.ts +123 -123
  40. package/dist/apps/data/routes/coingecko.d.ts +169 -169
  41. package/dist/apps/data/routes/funding.d.ts +230 -146
  42. package/dist/apps/data/routes/funding.d.ts.map +1 -1
  43. package/dist/apps/data/routes/funding.js +23 -12
  44. package/dist/apps/data/routes/funding.js.map +1 -1
  45. package/dist/apps/data/routes/indexer.d.ts +34 -34
  46. package/dist/apps/data/routes/indexer.d.ts.map +1 -1
  47. package/dist/apps/data/routes/indexer.js +9 -2
  48. package/dist/apps/data/routes/indexer.js.map +1 -1
  49. package/dist/apps/data/routes/receipts.d.ts +228 -228
  50. package/dist/apps/data/routes/tokenlist.d.ts +36 -36
  51. package/dist/apps/data/routes/transactions.d.ts +281 -281
  52. package/dist/apps/data/routes/transfers.d.ts +58 -58
  53. package/dist/apps/data/routes/verified-tokens.d.ts +86 -86
  54. package/dist/apps/management/App.d.ts +1042 -1042
  55. package/dist/apps/management/routes/api-keys.d.ts +120 -120
  56. package/dist/apps/management/routes/billing.d.ts +211 -211
  57. package/dist/apps/management/routes/invitations.d.ts +132 -132
  58. package/dist/apps/management/routes/invite-links.d.ts +62 -62
  59. package/dist/apps/management/routes/me.d.ts +17 -17
  60. package/dist/apps/management/routes/members.d.ts +80 -80
  61. package/dist/apps/management/routes/orgs.d.ts +96 -96
  62. package/dist/apps/management/routes/projects.d.ts +61 -61
  63. package/dist/apps/management/routes/scopes.d.ts +11 -11
  64. package/dist/apps/management/routes/usage.d.ts +79 -79
  65. package/dist/apps/mcp.js +2 -2
  66. package/dist/apps/mcp.js.map +1 -1
  67. package/dist/apps/relay/App.d.ts +2 -2
  68. package/dist/apps/relay/App.js +2 -2
  69. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  70. package/dist/db/tables/sponsoredTransactions.js +6 -2
  71. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  72. package/dist/internal/Auth.d.ts.map +1 -1
  73. package/dist/internal/Auth.js +8 -0
  74. package/dist/internal/Auth.js.map +1 -1
  75. package/dist/internal/FundingQuotes.d.ts +170 -56
  76. package/dist/internal/FundingQuotes.d.ts.map +1 -1
  77. package/dist/internal/FundingQuotes.js +91 -5
  78. package/dist/internal/FundingQuotes.js.map +1 -1
  79. package/dist/internal/Log.d.ts +18 -12
  80. package/dist/internal/Log.d.ts.map +1 -1
  81. package/dist/internal/Log.js +2 -0
  82. package/dist/internal/Log.js.map +1 -1
  83. package/dist/internal/Tidx.d.ts +9 -3
  84. package/dist/internal/Tidx.d.ts.map +1 -1
  85. package/dist/internal/Tidx.js +13 -7
  86. package/dist/internal/Tidx.js.map +1 -1
  87. package/dist/internal/Viem.d.ts +14 -1
  88. package/dist/internal/Viem.d.ts.map +1 -1
  89. package/dist/internal/Viem.js +19 -1
  90. package/dist/internal/Viem.js.map +1 -1
  91. package/package.json +2 -2
  92. package/src/ApiKeys.test.ts +2 -0
  93. package/src/App.test.ts +13 -2
  94. package/src/App.ts +31 -5
  95. package/src/Assets.ts +3 -3
  96. package/src/Client.test-d.ts +3 -2
  97. package/src/Scope.ts +4 -2
  98. package/src/Webhooks.ts +5 -10
  99. package/src/admin/apps/api-keys.test.ts +27 -0
  100. package/src/admin/apps/api-keys.ts +8 -1
  101. package/src/admin/apps/scopes.test.ts +7 -2
  102. package/src/admin/apps/scopes.ts +2 -2
  103. package/src/admin/ui/src/components/Dialog.tsx +8 -2
  104. package/src/admin/ui/src/lib/scopes.test.ts +19 -0
  105. package/src/admin/ui/src/lib/scopes.ts +12 -0
  106. package/src/admin/ui/src/routes/api-keys.tsx +128 -27
  107. package/src/admin/ui.generated.ts +33 -33
  108. package/src/apps/composition.test.ts +77 -0
  109. package/src/apps/data/App.ts +25 -45
  110. package/src/apps/data/FundingQuoteProviders.test.ts +431 -5
  111. package/src/apps/data/FundingQuoteProviders.ts +386 -4
  112. package/src/apps/data/routes/funding.test.ts +174 -7
  113. package/src/apps/data/routes/funding.ts +27 -12
  114. package/src/apps/data/routes/indexer.test.ts +82 -20
  115. package/src/apps/data/routes/indexer.ts +8 -2
  116. package/src/apps/management/Billing.test.ts +2 -2
  117. package/src/apps/mcp.test.ts +18 -0
  118. package/src/apps/mcp.ts +2 -2
  119. package/src/apps/relay/App.test.ts +6 -5
  120. package/src/apps/relay/App.ts +2 -2
  121. package/src/apps/relay/Sponsorships.test.ts +2 -2
  122. package/src/db/Db.test.ts +5 -5
  123. package/src/db/tables/billingSettings.test.ts +2 -2
  124. package/src/db/tables/organizations.test.ts +2 -2
  125. package/src/db/tables/sponsoredTransactions.test.ts +19 -2
  126. package/src/db/tables/sponsoredTransactions.ts +21 -14
  127. package/src/db/tables/stripeCustomers.test.ts +2 -2
  128. package/src/internal/Auth.ts +10 -0
  129. package/src/internal/FundingQuotes.test.ts +97 -3
  130. package/src/internal/FundingQuotes.ts +104 -6
  131. package/src/internal/Log.ts +21 -14
  132. package/src/internal/Tidx.test.ts +48 -0
  133. package/src/internal/Tidx.ts +21 -7
  134. package/src/internal/Viem.test.ts +46 -0
  135. package/src/internal/Viem.ts +34 -1
@@ -114,6 +114,8 @@ describe('mint', () => {
114
114
  'zone:abc:read',
115
115
  'zone:1.5:read',
116
116
  'zone:-1:read',
117
+ 'zone:0:read',
118
+ 'zone:000421700001:read',
117
119
  'zone:1:write',
118
120
  ])
119
121
  await expect(
package/src/App.test.ts CHANGED
@@ -74,6 +74,18 @@ describe('create', () => {
74
74
  `)
75
75
  })
76
76
 
77
+ test('ignores client-supplied request ids', async () => {
78
+ const entries: Log.Entry[] = []
79
+ const app = TestApp.create({ logger: (entry) => void entries.push(entry) })
80
+ const response = await app.request('/health', {
81
+ headers: { 'tempo-request-id': 'client-controlled' },
82
+ })
83
+ const requestId = response.headers.get('tempo-request-id')
84
+
85
+ expect(requestId === 'client-controlled').toMatchInlineSnapshot(`false`)
86
+ expect(entries[0]?.requestId === requestId).toMatchInlineSnapshot(`true`)
87
+ })
88
+
77
89
  test('documents supported API key transports', async () => {
78
90
  const app = TestApp.create()
79
91
  const spec = await (await app.request('/openapi.json')).json()
@@ -473,7 +485,7 @@ describe('create', () => {
473
485
  // key matches, so a `:chainId//icons` regression would drop every logo.
474
486
  const db = TestApp.database()
475
487
  await TestApp.verifiedSeed(db, 42431)
476
- const app = App.create({ db }).route(
488
+ const app = App.create({ db, defaultChainId: 42431 }).route(
477
489
  '/',
478
490
  App.data({
479
491
  assets: {
@@ -485,7 +497,6 @@ describe('create', () => {
485
497
  }
486
498
  : undefined,
487
499
  },
488
- defaultChainId: 42431,
489
500
  }),
490
501
  )
491
502
  const response = await app.request('/v1/tokens?verified=true')
package/src/App.ts CHANGED
@@ -149,6 +149,13 @@ export function create<const path extends string = '/'>(options: create.Options<
149
149
  // `options.db`, API-key records in `options.kv`.
150
150
  const store = options.cache?.store ?? Store.memory()
151
151
  const scopeCatalog = Scope.extend(options.scopes)
152
+ const defaultChainId = options.defaultChainId ?? Viem.defaultChainId
153
+ const getClient = Viem.createGetClient({ defaultChainId, rpc: options.rpc })
154
+ const supportedChainIds = new Set<number>([
155
+ ...Object.keys(Viem.url).map(Number),
156
+ defaultChainId,
157
+ ...(options.supportedChainIds ?? []),
158
+ ])
152
159
 
153
160
  // Build the app in two phases: register middleware imperatively, then chain
154
161
  // route mounts so the merged Hono schema flows through the return type. The
@@ -165,7 +172,11 @@ export function create<const path extends string = '/'>(options: create.Options<
165
172
  '*',
166
173
  hono_compress(typeof options.compress === 'object' ? options.compress : undefined),
167
174
  )
168
- base.use('*', requestId({ headerName: 'tempo-request-id' }))
175
+ base.use('*', requestId())
176
+ base.use('*', async (c, next) => {
177
+ await next()
178
+ c.header('tempo-request-id', c.get('requestId'))
179
+ })
169
180
  // One canonical structured log line per request, plus request metrics.
170
181
  // Registered after `requestId` (the entry embeds it) and before the timing and
171
182
  // edge-cache layers so the entry covers edge-cache hits and includes the
@@ -233,11 +244,15 @@ export function create<const path extends string = '/'>(options: create.Options<
233
244
  base.use('*', async (c, next) => {
234
245
  c.set('analytics', analytics)
235
246
  c.set('basePath', basePath)
247
+ c.set('chainId', defaultChainId)
236
248
  c.set('db', db.source)
237
249
  c.set('dbCached', db.cached ?? db.source)
250
+ c.set('getClient', (chainId) => getClient(chainId, Auth.getPrincipal(c)))
238
251
  c.set('kv', options.kv)
252
+ c.set('rpc', options.rpc)
239
253
  c.set('scopeCatalog', scopeCatalog)
240
254
  c.set('store', store)
255
+ c.set('supportedChainIds', supportedChainIds)
241
256
  await next()
242
257
  })
243
258
 
@@ -271,7 +286,10 @@ export function create<const path extends string = '/'>(options: create.Options<
271
286
  const route = app.route.bind(app)
272
287
  ;(app as unknown as { route: typeof route }).route = ((path, sub) => {
273
288
  const entry = Metadata.read(sub)
274
- if (entry) metadata.push(entry)
289
+ if (entry) {
290
+ metadata.push(entry)
291
+ for (const chainId of entry.supportedChainIds ?? []) supportedChainIds.add(chainId)
292
+ }
275
293
  return route(path, sub)
276
294
  }) as typeof route
277
295
 
@@ -442,9 +460,9 @@ export function listener(app: { fetch: NodeFetch }): Listener {
442
460
 
443
461
  export declare namespace create {
444
462
  /**
445
- * Options for creating the Tempo API Hono app the shared core:
446
- * db/cache/kv/auth capabilities and cross-cutting middleware. Chain-data
447
- * configuration belongs to {@link data}; email to {@link management}.
463
+ * Options for creating the Tempo API Hono app: shared chain, RPC, database,
464
+ * cache, authentication, and cross-cutting configuration. Email belongs to
465
+ * {@link management}.
448
466
  */
449
467
  type Options<path extends string = '/'> = {
450
468
  /** Analytics store for usage reads (e.g. `Analytics.clickhouse(...)`), or a per-request factory. Accepts `{ source }` to mirror `db`. Omit to leave usage analytics unconfigured — usage routes answer 501. Ingest happens outside this app (queue consumer via `Analytics.handleQueue`). */
@@ -459,6 +477,8 @@ export declare namespace create {
459
477
  cors?: boolean | Cors | undefined
460
478
  /** Authoritative Postgres store backing stateful features (webhooks, verified tokens). Pass a long-lived `Db.postgres(...)` singleton on Node, or a factory (`() => Db.postgres(...)`) on Workers so each request builds a fresh Hyperdrive-pooled connection. Pass `{ source, cached }` to route staleness-tolerant reads (verified tokens) through a second, query-caching Hyperdrive config; `source` stays uncached for read-after-write correctness. Apply migrations out of band via `tapimo admin migrate`. */
461
479
  db: Db.Source | { cached?: Db.Source | undefined; source: Db.Source }
480
+ /** Default Tempo chain id for requests that omit `chainId`. */
481
+ defaultChainId?: Viem.ChainId | undefined
462
482
  /** KV state store backing API-key records (e.g. `Store.cloudflareKv(...)`). Omit to leave API-key auth closed — every token resolves to `null`. */
463
483
  kv?: Kv | undefined
464
484
  /** Request logging. Disabled by default. Pass `true` to emit one canonical structured log entry per request through the level-matched console method (indexed by Workers Logs); a level (e.g. `'warn'`) to emit only that level and above (dropping successful-request info lines); or an emit function for a custom sink. */
@@ -467,10 +487,14 @@ export declare namespace create {
467
487
  metrics?: Metrics.Metrics | undefined
468
488
  /** Base path to mount API handlers under. Provided as a literal via the `path` generic so the resulting Hono schema keeps typed route paths for `testClient`. */
469
489
  path?: path | undefined
490
+ /** Tempo RPC options, including per-request routing by request principal. */
491
+ rpc?: Viem.getClient.Rpc | undefined
470
492
  /** OpenAPI `servers` for the generated document. Defaults to a single relative server (the host serving the doc), so the reference targets the deployment it is served from. Set the canonical public URL(s) here, e.g. `[{ url: 'https://api.tempo.xyz' }]`. */
471
493
  servers?: readonly Server[] | undefined
472
494
  /** Host-defined API-key scopes appended to the built-in {@link Scope.catalog}. */
473
495
  scopes?: readonly Scope.Entry[] | undefined
496
+ /** Extra chain ids served beyond the built-in chains and `defaultChainId`. */
497
+ supportedChainIds?: readonly number[] | undefined
474
498
  }
475
499
 
476
500
  /** KV state-store configuration. */
@@ -532,6 +556,8 @@ export declare namespace create {
532
556
  docs?: Docs | undefined
533
557
  /** Security scheme definitions this group's routes reference (e.g. the session cookie). */
534
558
  securitySchemes?: Record<string, unknown> | undefined
559
+ /** Chain ids served by this route group, merged before auth handles the first request. */
560
+ supportedChainIds?: readonly number[] | undefined
535
561
  /** Root-level OpenAPI tag definitions for this group's operations. */
536
562
  tags?: readonly Tag[] | undefined
537
563
  /** Builds the OAS 3.1 `webhooks` block documenting outbound deliveries, emitted only when the group's webhook routes are mounted. Called during spec generation only, keeping schema conversion off isolate startup. */
package/src/Assets.ts CHANGED
@@ -26,9 +26,9 @@ export type Assets = {
26
26
  export function cloudflareR2(bucket: cloudflareR2.Bucket): Assets {
27
27
  return {
28
28
  async get(key) {
29
- // Exact keys sort first, so expected misses avoid `NoSuchKey` spans.
30
- const { objects } = await bucket.list({ limit: 1, prefix: key })
31
- if (objects[0]?.key !== key) return undefined
29
+ // Legacy source-extension aliases may sort before the extensionless key.
30
+ const { objects } = await bucket.list({ limit: 1_000, prefix: key })
31
+ if (!objects.some((object) => object.key === key)) return undefined
32
32
  const object = await bucket.get(key)
33
33
  if (!object) return undefined
34
34
  return {
@@ -1193,7 +1193,7 @@ describe('narrows responses by status', () => {
1193
1193
  const client = Client.create()
1194
1194
  const res = await client.v1.funding.quotes.$get({
1195
1195
  query: {
1196
- provider: 'relay',
1196
+ provider: 'across',
1197
1197
  sourceAmount: '1000000',
1198
1198
  sourceChain: 'base',
1199
1199
  sourceToken: 'USDC',
@@ -1208,7 +1208,8 @@ describe('narrows responses by status', () => {
1208
1208
  expectTypeOf(json.data[0]!.sourceAmount).toEqualTypeOf<
1209
1209
  z.output<typeof Funding.schema.quoteFunding.Response>['data'][number]['sourceAmount']
1210
1210
  >()
1211
- expectTypeOf(json.data[0]!.provider.id).toEqualTypeOf<'relay'>()
1211
+ expectTypeOf(json.data[0]!.provider.id).toEqualTypeOf<'across' | 'relay' | 'rhino'>()
1212
+ expectTypeOf(json.data[0]!.quote.expiresAt).toEqualTypeOf<string | undefined>()
1212
1213
  expectTypeOf(json.data[0]!.quote.outputAmount).toEqualTypeOf<string>()
1213
1214
  void json
1214
1215
  return
package/src/Scope.ts CHANGED
@@ -86,8 +86,10 @@ export namespace schema {
86
86
  /** A concrete per-zone read scope (`zone:421700001:read`). */
87
87
  export const Zone = z
88
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`).'))
89
+ .check(
90
+ z.regex(/^zone:[1-9]\d*:read$/),
91
+ z.describe('Per-zone read scope (`zone:<chainId>:read`).'),
92
+ )
91
93
  }
92
94
 
93
95
  /** Per-zone read scope, granting access to one zone chain id. */
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