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.
- package/dist/App.d.ts +4879 -4870
- package/dist/App.d.ts.map +1 -1
- package/dist/App.js +20 -2
- package/dist/App.js.map +1 -1
- package/dist/Assets.js +3 -3
- package/dist/Assets.js.map +1 -1
- package/dist/Scope.d.ts.map +1 -1
- package/dist/Scope.js +1 -2
- package/dist/Scope.js.map +1 -1
- package/dist/Webhooks.d.ts +1 -1
- package/dist/Webhooks.d.ts.map +1 -1
- package/dist/Webhooks.js +5 -10
- package/dist/Webhooks.js.map +1 -1
- package/dist/admin/App.d.ts +6 -34
- package/dist/admin/App.d.ts.map +1 -1
- package/dist/admin/apps/api-keys.d.ts +3 -3
- package/dist/admin/apps/api-keys.d.ts.map +1 -1
- package/dist/admin/apps/api-keys.js +4 -1
- package/dist/admin/apps/api-keys.js.map +1 -1
- package/dist/admin/apps/scopes.d.ts +6 -34
- package/dist/admin/apps/scopes.d.ts.map +1 -1
- package/dist/admin/apps/scopes.js +2 -2
- package/dist/admin/apps/scopes.js.map +1 -1
- package/dist/admin/apps/verified-tokens.d.ts +116 -116
- package/dist/admin/ui.generated.d.ts +1 -1
- package/dist/admin/ui.generated.js +33 -33
- package/dist/admin/ui.generated.js.map +1 -1
- package/dist/apps/data/App.d.ts +3199 -3206
- package/dist/apps/data/App.d.ts.map +1 -1
- package/dist/apps/data/App.js +25 -38
- package/dist/apps/data/App.js.map +1 -1
- package/dist/apps/data/FundingQuoteProviders.d.ts +76 -3
- package/dist/apps/data/FundingQuoteProviders.d.ts.map +1 -1
- package/dist/apps/data/FundingQuoteProviders.js +276 -2
- package/dist/apps/data/FundingQuoteProviders.js.map +1 -1
- package/dist/apps/data/routes/activities.d.ts +245 -245
- package/dist/apps/data/routes/addresses.d.ts +206 -206
- package/dist/apps/data/routes/balances.d.ts +46 -46
- package/dist/apps/data/routes/blocks.d.ts +123 -123
- package/dist/apps/data/routes/coingecko.d.ts +169 -169
- package/dist/apps/data/routes/funding.d.ts +230 -146
- package/dist/apps/data/routes/funding.d.ts.map +1 -1
- package/dist/apps/data/routes/funding.js +23 -12
- package/dist/apps/data/routes/funding.js.map +1 -1
- package/dist/apps/data/routes/indexer.d.ts +34 -34
- package/dist/apps/data/routes/indexer.d.ts.map +1 -1
- package/dist/apps/data/routes/indexer.js +9 -2
- package/dist/apps/data/routes/indexer.js.map +1 -1
- package/dist/apps/data/routes/receipts.d.ts +228 -228
- package/dist/apps/data/routes/tokenlist.d.ts +36 -36
- package/dist/apps/data/routes/transactions.d.ts +281 -281
- package/dist/apps/data/routes/transfers.d.ts +58 -58
- package/dist/apps/data/routes/verified-tokens.d.ts +86 -86
- package/dist/apps/management/App.d.ts +1042 -1042
- package/dist/apps/management/routes/api-keys.d.ts +120 -120
- package/dist/apps/management/routes/billing.d.ts +211 -211
- package/dist/apps/management/routes/invitations.d.ts +132 -132
- package/dist/apps/management/routes/invite-links.d.ts +62 -62
- package/dist/apps/management/routes/me.d.ts +17 -17
- package/dist/apps/management/routes/members.d.ts +80 -80
- package/dist/apps/management/routes/orgs.d.ts +96 -96
- package/dist/apps/management/routes/projects.d.ts +61 -61
- package/dist/apps/management/routes/scopes.d.ts +11 -11
- package/dist/apps/management/routes/usage.d.ts +79 -79
- package/dist/apps/mcp.js +2 -2
- package/dist/apps/mcp.js.map +1 -1
- package/dist/apps/relay/App.d.ts +2 -2
- package/dist/apps/relay/App.js +2 -2
- package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
- package/dist/db/tables/sponsoredTransactions.js +6 -2
- package/dist/db/tables/sponsoredTransactions.js.map +1 -1
- package/dist/internal/Auth.d.ts.map +1 -1
- package/dist/internal/Auth.js +8 -0
- package/dist/internal/Auth.js.map +1 -1
- package/dist/internal/FundingQuotes.d.ts +170 -56
- package/dist/internal/FundingQuotes.d.ts.map +1 -1
- package/dist/internal/FundingQuotes.js +91 -5
- package/dist/internal/FundingQuotes.js.map +1 -1
- package/dist/internal/Log.d.ts +18 -12
- package/dist/internal/Log.d.ts.map +1 -1
- package/dist/internal/Log.js +2 -0
- package/dist/internal/Log.js.map +1 -1
- package/dist/internal/Tidx.d.ts +9 -3
- package/dist/internal/Tidx.d.ts.map +1 -1
- package/dist/internal/Tidx.js +13 -7
- package/dist/internal/Tidx.js.map +1 -1
- package/dist/internal/Viem.d.ts +14 -1
- package/dist/internal/Viem.d.ts.map +1 -1
- package/dist/internal/Viem.js +19 -1
- package/dist/internal/Viem.js.map +1 -1
- package/package.json +2 -2
- package/src/ApiKeys.test.ts +2 -0
- package/src/App.test.ts +13 -2
- package/src/App.ts +31 -5
- package/src/Assets.ts +3 -3
- package/src/Client.test-d.ts +3 -2
- package/src/Scope.ts +4 -2
- package/src/Webhooks.ts +5 -10
- package/src/admin/apps/api-keys.test.ts +27 -0
- package/src/admin/apps/api-keys.ts +8 -1
- package/src/admin/apps/scopes.test.ts +7 -2
- package/src/admin/apps/scopes.ts +2 -2
- package/src/admin/ui/src/components/Dialog.tsx +8 -2
- package/src/admin/ui/src/lib/scopes.test.ts +19 -0
- package/src/admin/ui/src/lib/scopes.ts +12 -0
- package/src/admin/ui/src/routes/api-keys.tsx +128 -27
- package/src/admin/ui.generated.ts +33 -33
- package/src/apps/composition.test.ts +77 -0
- package/src/apps/data/App.ts +25 -45
- package/src/apps/data/FundingQuoteProviders.test.ts +431 -5
- package/src/apps/data/FundingQuoteProviders.ts +386 -4
- package/src/apps/data/routes/funding.test.ts +174 -7
- package/src/apps/data/routes/funding.ts +27 -12
- package/src/apps/data/routes/indexer.test.ts +82 -20
- package/src/apps/data/routes/indexer.ts +8 -2
- package/src/apps/management/Billing.test.ts +2 -2
- package/src/apps/mcp.test.ts +18 -0
- package/src/apps/mcp.ts +2 -2
- package/src/apps/relay/App.test.ts +6 -5
- package/src/apps/relay/App.ts +2 -2
- package/src/apps/relay/Sponsorships.test.ts +2 -2
- package/src/db/Db.test.ts +5 -5
- package/src/db/tables/billingSettings.test.ts +2 -2
- package/src/db/tables/organizations.test.ts +2 -2
- package/src/db/tables/sponsoredTransactions.test.ts +19 -2
- package/src/db/tables/sponsoredTransactions.ts +21 -14
- package/src/db/tables/stripeCustomers.test.ts +2 -2
- package/src/internal/Auth.ts +10 -0
- package/src/internal/FundingQuotes.test.ts +97 -3
- package/src/internal/FundingQuotes.ts +104 -6
- package/src/internal/Log.ts +21 -14
- package/src/internal/Tidx.test.ts +48 -0
- package/src/internal/Tidx.ts +21 -7
- package/src/internal/Viem.test.ts +46 -0
- package/src/internal/Viem.ts +34 -1
package/src/ApiKeys.test.ts
CHANGED
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(
|
|
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)
|
|
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
|
|
446
|
-
*
|
|
447
|
-
*
|
|
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
|
-
//
|
|
30
|
-
const { objects } = await bucket.list({ limit:
|
|
31
|
-
if (objects
|
|
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 {
|
package/src/Client.test-d.ts
CHANGED
|
@@ -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: '
|
|
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
|
-
|
|
90
|
-
|
|
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.
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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(
|
|
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
|
|
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.
|
|
72
|
+
expect(body.data).toEqual(Scope.extend())
|
|
68
73
|
})
|
|
69
74
|
|
|
70
75
|
test('is never cached (admin responses are per-principal)', async () => {
|
package/src/admin/apps/scopes.ts
CHANGED
|
@@ -33,7 +33,7 @@ export namespace schema {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/** Catalog of
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
<
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|