tapimo 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (257) hide show
  1. package/dist/App.d.ts +5561 -5026
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js +38 -3
  4. package/dist/App.js.map +1 -1
  5. package/dist/Scope.d.ts +2 -0
  6. package/dist/Scope.d.ts.map +1 -1
  7. package/dist/Scope.js +17 -0
  8. package/dist/Scope.js.map +1 -1
  9. package/dist/admin/App.d.ts +123 -74
  10. package/dist/admin/App.d.ts.map +1 -1
  11. package/dist/admin/apps/api-keys.d.ts +80 -0
  12. package/dist/admin/apps/api-keys.d.ts.map +1 -1
  13. package/dist/admin/apps/api-keys.js +54 -0
  14. package/dist/admin/apps/api-keys.js.map +1 -1
  15. package/dist/admin/apps/earn-vaults.d.ts +15 -15
  16. package/dist/admin/apps/earn-vaults.js +1 -1
  17. package/dist/admin/apps/verified-tokens.d.ts +81 -81
  18. package/dist/admin/ui.generated.d.ts +1 -1
  19. package/dist/admin/ui.generated.d.ts.map +1 -1
  20. package/dist/admin/ui.generated.js +151 -151
  21. package/dist/admin/ui.generated.js.map +1 -1
  22. package/dist/apps/Zones.d.ts +16 -16
  23. package/dist/apps/Zones.js +4 -4
  24. package/dist/apps/data/App.js +18 -18
  25. package/dist/apps/data/App.js.map +1 -1
  26. package/dist/apps/data/ZoneSelection.d.ts +8 -8
  27. package/dist/apps/data/ZoneSelection.d.ts.map +1 -1
  28. package/dist/apps/data/ZoneSelection.js +1 -1
  29. package/dist/apps/data/ZoneSelection.js.map +1 -1
  30. package/dist/apps/data/routes/activities.d.ts +588 -584
  31. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  32. package/dist/apps/data/routes/activities.js +1561 -1239
  33. package/dist/apps/data/routes/activities.js.map +1 -1
  34. package/dist/apps/data/routes/balances.d.ts +26 -26
  35. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  36. package/dist/apps/data/routes/balances.js +43 -16
  37. package/dist/apps/data/routes/balances.js.map +1 -1
  38. package/dist/apps/data/routes/blocks.d.ts +44 -44
  39. package/dist/apps/data/routes/blocks.d.ts.map +1 -1
  40. package/dist/apps/data/routes/blocks.js +21 -11
  41. package/dist/apps/data/routes/blocks.js.map +1 -1
  42. package/dist/apps/data/routes/coingecko.d.ts +3 -3
  43. package/dist/apps/data/routes/earn.d.ts +18 -18
  44. package/dist/apps/data/routes/earn.js +13 -13
  45. package/dist/apps/data/routes/exchanges.d.ts +145 -145
  46. package/dist/apps/data/routes/fee-amm.d.ts +2 -2
  47. package/dist/apps/data/routes/receipts.d.ts +155 -55
  48. package/dist/apps/data/routes/receipts.d.ts.map +1 -1
  49. package/dist/apps/data/routes/receipts.js +28 -10
  50. package/dist/apps/data/routes/receipts.js.map +1 -1
  51. package/dist/apps/data/routes/tokenlist.d.ts +13 -13
  52. package/dist/apps/data/routes/tokens.d.ts +98 -98
  53. package/dist/apps/data/routes/tokens.d.ts.map +1 -1
  54. package/dist/apps/data/routes/tokens.js +51 -20
  55. package/dist/apps/data/routes/tokens.js.map +1 -1
  56. package/dist/apps/data/routes/transactions.d.ts +7 -2
  57. package/dist/apps/data/routes/transactions.d.ts.map +1 -1
  58. package/dist/apps/data/routes/transactions.js +37 -17
  59. package/dist/apps/data/routes/transactions.js.map +1 -1
  60. package/dist/apps/data/routes/transfers.d.ts +1 -1
  61. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  62. package/dist/apps/data/routes/transfers.js +18 -12
  63. package/dist/apps/data/routes/transfers.js.map +1 -1
  64. package/dist/apps/data/routes/valuation.d.ts +1 -1
  65. package/dist/apps/data/routes/valuation.d.ts.map +1 -1
  66. package/dist/apps/data/routes/valuation.js +12 -7
  67. package/dist/apps/data/routes/valuation.js.map +1 -1
  68. package/dist/apps/data/routes/verified-tokens.d.ts +3 -3
  69. package/dist/apps/data/routes/webhooks.d.ts +11 -11
  70. package/dist/apps/data/routes/zones.d.ts +14 -14
  71. package/dist/apps/data/routes/zones.js +1 -1
  72. package/dist/apps/funding/App.d.ts +11 -0
  73. package/dist/apps/funding/App.d.ts.map +1 -1
  74. package/dist/apps/funding/routes/deposit-addresses.d.ts.map +1 -1
  75. package/dist/apps/funding/routes/deposit-addresses.js +16 -11
  76. package/dist/apps/funding/routes/deposit-addresses.js.map +1 -1
  77. package/dist/apps/funding/routes/transfers.d.ts +60 -48
  78. package/dist/apps/funding/routes/transfers.d.ts.map +1 -1
  79. package/dist/apps/funding/routes/transfers.js +27 -19
  80. package/dist/apps/funding/routes/transfers.js.map +1 -1
  81. package/dist/apps/management/App.d.ts +4487 -3963
  82. package/dist/apps/management/App.d.ts.map +1 -1
  83. package/dist/apps/management/routes/api-keys.d.ts +710 -159
  84. package/dist/apps/management/routes/api-keys.d.ts.map +1 -1
  85. package/dist/apps/management/routes/api-keys.js +236 -4
  86. package/dist/apps/management/routes/api-keys.js.map +1 -1
  87. package/dist/apps/management/routes/faucet.d.ts +6 -6
  88. package/dist/apps/management/routes/invite-links.d.ts +42 -42
  89. package/dist/apps/management/routes/members.d.ts +30 -30
  90. package/dist/apps/management/routes/projects.d.ts +40 -40
  91. package/dist/apps/management/routes/usage.d.ts +24 -24
  92. package/dist/apps/management/routes/verified-token-requests.d.ts +1 -1
  93. package/dist/apps/management/routes/webhooks.d.ts +55 -55
  94. package/dist/db/Db.d.ts.map +1 -1
  95. package/dist/db/Db.js +12 -1
  96. package/dist/db/Db.js.map +1 -1
  97. package/dist/db/Schema.d.ts +3 -2
  98. package/dist/db/Schema.d.ts.map +1 -1
  99. package/dist/db/Schema.js +5 -2
  100. package/dist/db/Schema.js.map +1 -1
  101. package/dist/db/tables/fundingCatalog.d.ts +4 -2
  102. package/dist/db/tables/fundingCatalog.d.ts.map +1 -1
  103. package/dist/db/tables/fundingCatalog.js +5 -1
  104. package/dist/db/tables/fundingCatalog.js.map +1 -1
  105. package/dist/handlers/internal/utils.d.ts.map +1 -1
  106. package/dist/handlers/internal/utils.js +9 -0
  107. package/dist/handlers/internal/utils.js.map +1 -1
  108. package/dist/internal/Auth.d.ts +3 -1
  109. package/dist/internal/Auth.d.ts.map +1 -1
  110. package/dist/internal/Auth.js +6 -3
  111. package/dist/internal/Auth.js.map +1 -1
  112. package/dist/internal/EarnVaults.d.ts +3 -3
  113. package/dist/internal/EarnVaults.js +3 -3
  114. package/dist/internal/Fetch.d.ts +23 -0
  115. package/dist/internal/Fetch.d.ts.map +1 -0
  116. package/dist/internal/Fetch.js +26 -0
  117. package/dist/internal/Fetch.js.map +1 -0
  118. package/dist/internal/Log.d.ts +3 -1
  119. package/dist/internal/Log.d.ts.map +1 -1
  120. package/dist/internal/Log.js +16 -3
  121. package/dist/internal/Log.js.map +1 -1
  122. package/dist/internal/OpenApi.d.ts +5 -3
  123. package/dist/internal/OpenApi.d.ts.map +1 -1
  124. package/dist/internal/OpenApi.js +13 -3
  125. package/dist/internal/OpenApi.js.map +1 -1
  126. package/dist/internal/Response.d.ts +1 -1
  127. package/dist/internal/Response.d.ts.map +1 -1
  128. package/dist/internal/Schema.d.ts +6 -3
  129. package/dist/internal/Schema.d.ts.map +1 -1
  130. package/dist/internal/Schema.js +8 -2
  131. package/dist/internal/Schema.js.map +1 -1
  132. package/dist/internal/Store.d.ts +16 -3
  133. package/dist/internal/Store.d.ts.map +1 -1
  134. package/dist/internal/Store.js +82 -16
  135. package/dist/internal/Store.js.map +1 -1
  136. package/dist/internal/Tidx.d.ts.map +1 -1
  137. package/dist/internal/Tidx.js +7 -2
  138. package/dist/internal/Tidx.js.map +1 -1
  139. package/dist/internal/Timeout.d.ts +36 -0
  140. package/dist/internal/Timeout.d.ts.map +1 -0
  141. package/dist/internal/Timeout.js +37 -0
  142. package/dist/internal/Timeout.js.map +1 -0
  143. package/dist/internal/Timing.d.ts +9 -0
  144. package/dist/internal/Timing.d.ts.map +1 -1
  145. package/dist/internal/Timing.js +7 -0
  146. package/dist/internal/Timing.js.map +1 -1
  147. package/dist/internal/WebhookDestination.d.ts.map +1 -1
  148. package/dist/internal/WebhookDestination.js +3 -0
  149. package/dist/internal/WebhookDestination.js.map +1 -1
  150. package/dist/internal/funding/Action.d.ts +9 -0
  151. package/dist/internal/funding/Action.d.ts.map +1 -1
  152. package/dist/internal/funding/Action.js +16 -8
  153. package/dist/internal/funding/Action.js.map +1 -1
  154. package/dist/internal/funding/Catalog.d.ts +9 -2
  155. package/dist/internal/funding/Catalog.d.ts.map +1 -1
  156. package/dist/internal/funding/Catalog.js +19 -8
  157. package/dist/internal/funding/Catalog.js.map +1 -1
  158. package/dist/internal/funding/Provider.d.ts +68 -13
  159. package/dist/internal/funding/Provider.d.ts.map +1 -1
  160. package/dist/internal/funding/Provider.js +29 -8
  161. package/dist/internal/funding/Provider.js.map +1 -1
  162. package/dist/internal/funding/Transfer.d.ts +13 -12
  163. package/dist/internal/funding/Transfer.d.ts.map +1 -1
  164. package/dist/internal/funding/Transfer.js +23 -21
  165. package/dist/internal/funding/Transfer.js.map +1 -1
  166. package/dist/internal/funding/providers/stargate.d.ts +28 -9
  167. package/dist/internal/funding/providers/stargate.d.ts.map +1 -1
  168. package/dist/internal/funding/providers/stargate.js +251 -117
  169. package/dist/internal/funding/providers/stargate.js.map +1 -1
  170. package/dist/internal/index.d.ts +1 -0
  171. package/dist/internal/index.d.ts.map +1 -1
  172. package/dist/internal/index.js +1 -0
  173. package/dist/internal/index.js.map +1 -1
  174. package/package.json +2 -2
  175. package/src/App.test.ts +100 -0
  176. package/src/App.ts +42 -3
  177. package/src/Client.test-d.ts +46 -0
  178. package/src/Scope.test.ts +20 -0
  179. package/src/Scope.ts +16 -0
  180. package/src/admin/apps/api-keys.test.ts +75 -0
  181. package/src/admin/apps/api-keys.ts +64 -0
  182. package/src/admin/apps/earn-vaults.test.ts +1 -1
  183. package/src/admin/apps/earn-vaults.ts +1 -1
  184. package/src/admin/ui/src/lib/api-keys.ts +19 -0
  185. package/src/admin/ui/src/routes/api-keys.tsx +89 -2
  186. package/src/admin/ui.generated.ts +189 -151
  187. package/src/apps/Zones.test.ts +4 -4
  188. package/src/apps/Zones.ts +4 -4
  189. package/src/apps/composition.test.ts +1 -1
  190. package/src/apps/data/App.test.ts +41 -0
  191. package/src/apps/data/App.ts +18 -18
  192. package/src/apps/data/ZoneSelection.test.ts +2 -2
  193. package/src/apps/data/ZoneSelection.ts +8 -8
  194. package/src/apps/data/routes/activities.test.ts +1526 -12
  195. package/src/apps/data/routes/activities.ts +1774 -1356
  196. package/src/apps/data/routes/balances.ts +133 -97
  197. package/src/apps/data/routes/blocks.test.ts +533 -0
  198. package/src/apps/data/routes/blocks.ts +35 -16
  199. package/src/apps/data/routes/earn.test.ts +4 -2
  200. package/src/apps/data/routes/earn.ts +13 -13
  201. package/src/apps/data/routes/earn.zone-access.test.ts +6 -6
  202. package/src/apps/data/routes/receipts.ts +47 -16
  203. package/src/apps/data/routes/tokens.test.ts +578 -0
  204. package/src/apps/data/routes/tokens.ts +283 -225
  205. package/src/apps/data/routes/transactions.ts +68 -29
  206. package/src/apps/data/routes/transfers.test.ts +421 -0
  207. package/src/apps/data/routes/transfers.ts +45 -27
  208. package/src/apps/data/routes/valuation.ts +94 -78
  209. package/src/apps/data/routes/zones.test.ts +2 -2
  210. package/src/apps/data/routes/zones.ts +1 -1
  211. package/src/apps/funding/routes/deposit-addresses.test.ts +25 -3
  212. package/src/apps/funding/routes/deposit-addresses.ts +17 -12
  213. package/src/apps/funding/routes/quotes.test.ts +5 -9
  214. package/src/apps/funding/routes/transfers.test.ts +395 -8
  215. package/src/apps/funding/routes/transfers.ts +76 -59
  216. package/src/apps/management/routes/api-keys.test.ts +220 -0
  217. package/src/apps/management/routes/api-keys.ts +280 -3
  218. package/src/db/Db.ts +13 -1
  219. package/src/db/Schema.ts +5 -2
  220. package/src/db/tables/earnVaults.test.ts +9 -9
  221. package/src/db/tables/fundingCatalog.test.ts +16 -2
  222. package/src/db/tables/fundingCatalog.ts +9 -3
  223. package/src/handlers/internal/utils.test.ts +53 -0
  224. package/src/handlers/internal/utils.ts +15 -0
  225. package/src/internal/Auth.ts +12 -4
  226. package/src/internal/EarnVaults.capabilities.test.ts +2 -2
  227. package/src/internal/EarnVaults.test.ts +1 -1
  228. package/src/internal/EarnVaults.ts +3 -3
  229. package/src/internal/Fetch.test.ts +46 -0
  230. package/src/internal/Fetch.ts +56 -0
  231. package/src/internal/Log.test.ts +21 -0
  232. package/src/internal/Log.ts +20 -5
  233. package/src/internal/OpenApi.ts +14 -4
  234. package/src/internal/Response.ts +1 -1
  235. package/src/internal/Schema.test.ts +2 -0
  236. package/src/internal/Schema.ts +12 -2
  237. package/src/internal/Store.test.ts +178 -0
  238. package/src/internal/Store.ts +104 -18
  239. package/src/internal/Tidx.test.ts +2 -1
  240. package/src/internal/Tidx.ts +7 -2
  241. package/src/internal/Timeout.test.ts +35 -0
  242. package/src/internal/Timeout.ts +73 -0
  243. package/src/internal/Timing.ts +16 -0
  244. package/src/internal/WebhookDestination.ts +3 -0
  245. package/src/internal/funding/Action.test.ts +41 -1
  246. package/src/internal/funding/Action.ts +69 -41
  247. package/src/internal/funding/Catalog.test.ts +34 -0
  248. package/src/internal/funding/Catalog.ts +28 -11
  249. package/src/internal/funding/Provider.test.ts +73 -0
  250. package/src/internal/funding/Provider.ts +82 -28
  251. package/src/internal/funding/Transfer.ts +133 -110
  252. package/src/internal/funding/providers/index.test.ts +4 -167
  253. package/src/internal/funding/providers/relay.test.ts +2 -2
  254. package/src/internal/funding/providers/stargate.test.ts +163 -0
  255. package/src/internal/funding/providers/stargate.ts +359 -134
  256. package/src/internal/index.test.ts +1 -0
  257. package/src/internal/index.ts +1 -0
package/src/App.ts CHANGED
@@ -5,6 +5,7 @@ import { compress as hono_compress } from 'hono/compress'
5
5
  import { cors as hono_cors } from 'hono/cors'
6
6
  import { HTTPException } from 'hono/http-exception'
7
7
  import { requestId, type RequestIdVariables } from 'hono/request-id'
8
+ import { timeout as hono_timeout } from 'hono/timeout'
8
9
  import { generateSpecs } from 'hono-openapi'
9
10
  import type { Chain } from 'viem'
10
11
 
@@ -183,6 +184,10 @@ export declare namespace GetAsset {
183
184
  /** Top-level OpenAPI `info.description`, rendered above the reference. */
184
185
  const infoDescription = 'REST API for reading and interacting with Tempo'
185
186
 
187
+ // Comfortably above the slowest legitimate time-to-response observed in
188
+ // production (~40s tidx reads), far below the 17-100 minute stalls it bounds.
189
+ const requestTimeoutMs = 60_000
190
+
186
191
  /** Creates the Tempo API Hono app. */
187
192
  export function create<const path extends string = '/'>(options: create.Options<path>) {
188
193
  const { path = '/' as path } = options
@@ -272,8 +277,8 @@ export function create<const path extends string = '/'>(options: create.Options<
272
277
  base.use(
273
278
  '*',
274
279
  Log.middleware<Environment>({
275
- emit: async (entry) => {
276
- await log?.(entry)
280
+ emit: async (entry, cause) => {
281
+ await log?.(entry, cause)
277
282
  recordRequest?.(entry)
278
283
  // Return the analytics promise so `Log.middleware` extends the
279
284
  // response with `waitUntil`; otherwise the async sink's `queue.send`
@@ -284,6 +289,30 @@ export function create<const path extends string = '/'>(options: create.Options<
284
289
  }),
285
290
  )
286
291
 
292
+ // Bounds time-to-response only; streaming bodies are unaffected. Without a
293
+ // deadline, a request stalled on an unbounded upstream wait pins its
294
+ // invocation (and isolate memory) until the client disconnects.
295
+ const deadline = hono_timeout(
296
+ requestTimeoutMs,
297
+ (c) =>
298
+ new HTTPException(504, {
299
+ res: Response.error(c, {
300
+ code: 'request_timeout',
301
+ message: 'Request timed out',
302
+ status: 504,
303
+ }),
304
+ }),
305
+ )
306
+ // Safe methods only: the expired handler is abandoned, not aborted, so a
307
+ // mutation could still commit after the 504. Paid requests are also exempt:
308
+ // MPP consumes payment before the handler, and the Payment-Receipt attaches
309
+ // only when the handler returns through the payment wrapper.
310
+ base.use('*', (c, next) => {
311
+ if (c.req.method !== 'GET' && c.req.method !== 'HEAD') return next()
312
+ if (Auth.hasPaymentCredential(c)) return next()
313
+ return deadline(c, next)
314
+ })
315
+
287
316
  // Normalize the `db` option: the plain form is `{ source }` with no cached
288
317
  // side; `cached` falls back to `source` so consumers never branch.
289
318
  const db = 'source' in options.db ? options.db : { source: options.db }
@@ -331,7 +360,7 @@ export function create<const path extends string = '/'>(options: create.Options<
331
360
  c.set('providers', providers)
332
361
  c.set('rpc', options.rpc)
333
362
  c.set('scopeCatalog', scopeCatalog)
334
- c.set('store', Store.withRequest(store, c.req))
363
+ c.set('store', Store.withRequest(store, c.req, { waitUntil: getWaitUntil(c) }))
335
364
  c.set('supportedChainIds', supportedChainIds)
336
365
  c.set('zones', zones)
337
366
  await next()
@@ -895,6 +924,16 @@ namespace ResponseBody {
895
924
  }
896
925
  }
897
926
 
927
+ function getWaitUntil(c: { executionCtx?: { waitUntil(promise: Promise<unknown>): void } }) {
928
+ // `c.executionCtx` throws in runtimes without one, so cache persistence falls back to an awaited write.
929
+ try {
930
+ const context = c.executionCtx
931
+ return context ? context.waitUntil.bind(context) : undefined
932
+ } catch {
933
+ return undefined
934
+ }
935
+ }
936
+
898
937
  namespace NodeServer {
899
938
  type GetRequestListener = (typeof import('@hono/node-server'))['getRequestListener']
900
939
  export type Fetch = Parameters<GetRequestListener>[0]
@@ -3,6 +3,7 @@ import { Mpp as PublicMpp } from 'tapimo/mpp'
3
3
  import type * as z from 'zod/mini'
4
4
 
5
5
  import * as Activities from './apps/data/routes/activities.js'
6
+ import * as ApiKeys from './apps/management/routes/api-keys.js'
6
7
  import * as Balances from './apps/data/routes/balances.js'
7
8
  import * as Billing from './apps/management/routes/billing.js'
8
9
  import * as Blocks from './apps/data/routes/blocks.js'
@@ -82,6 +83,50 @@ test('returns typed client', async () => {
82
83
  })
83
84
 
84
85
  describe('narrows responses by status', () => {
86
+ test('POST /v1/orgs/:orgId/projects/:projectId/api-keys/:keyId/rotate', async () => {
87
+ const client = Client.create()
88
+ const res = await client.v1.orgs[':orgId{org_[A-Za-z0-9_-]+}'].projects[
89
+ ':projectId{prj_[A-Za-z0-9_-]+}'
90
+ ]['api-keys'][':keyId{key_[A-Za-z0-9_-]+}'].rotate.$post({
91
+ param: { keyId: 'key_test', orgId: 'org_test', projectId: 'prj_test' },
92
+ })
93
+
94
+ expectTypeOf(res.status).toEqualTypeOf<200 | 400 | 401 | 403 | 404 | 502>()
95
+
96
+ if (res.status === 200) {
97
+ const json = await res.json()
98
+ expectTypeOf(json).toEqualTypeOf<z.output<typeof ApiKeys.schema.rotateApiKey.Response>>()
99
+ }
100
+ })
101
+
102
+ test('POST /v1/orgs/:orgId/api-keys/:keyId/rotate', async () => {
103
+ const client = Client.create()
104
+ const res = await client.v1.orgs[':orgId{org_[A-Za-z0-9_-]+}']['api-keys'][
105
+ ':keyId{key_[A-Za-z0-9_-]+}'
106
+ ].rotate.$post({ param: { keyId: 'key_test', orgId: 'org_test' } })
107
+
108
+ expectTypeOf(res.status).toEqualTypeOf<200 | 400 | 401 | 403 | 404 | 502>()
109
+
110
+ if (res.status === 200) {
111
+ const json = await res.json()
112
+ expectTypeOf(json).toEqualTypeOf<z.output<typeof ApiKeys.schema.rotateApiKey.Response>>()
113
+ }
114
+ })
115
+
116
+ test('DELETE /v1/orgs/:orgId/api-keys/:keyId', async () => {
117
+ const client = Client.create()
118
+ const res = await client.v1.orgs[':orgId{org_[A-Za-z0-9_-]+}']['api-keys'][
119
+ ':keyId{key_[A-Za-z0-9_-]+}'
120
+ ].$delete({ param: { keyId: 'key_test', orgId: 'org_test' } })
121
+
122
+ expectTypeOf(res.status).toEqualTypeOf<200 | 400 | 401 | 403 | 404 | 429 | 502>()
123
+
124
+ if (res.status === 200) {
125
+ const json = await res.json()
126
+ expectTypeOf(json).toEqualTypeOf<z.output<typeof ApiKeys.schema.revokeApiKey.Response>>()
127
+ }
128
+ })
129
+
85
130
  test('POST /v1/mpp/validate', async () => {
86
131
  const client = Client.create()
87
132
  const res = await client.v1.mpp.validate.$post({ json: mppInput })
@@ -2563,6 +2608,7 @@ describe('narrows responses by status', () => {
2563
2608
  amount: '1000000',
2564
2609
  destinationToken: '0x20c000000000000000000000b9537d11c60e8b50',
2565
2610
  mode: 'exactSource',
2611
+ provider: 'stargate',
2566
2612
  recipient: `0x${'11'.repeat(20)}`,
2567
2613
  sender: `0x${'22'.repeat(20)}`,
2568
2614
  sourceChain: 'eip155:8453',
package/src/Scope.test.ts CHANGED
@@ -52,3 +52,23 @@ describe('extend', () => {
52
52
  ).toThrowErrorMatchingInlineSnapshot(`[Error: Duplicate scope \`data:read\`.]`)
53
53
  })
54
54
  })
55
+
56
+ describe('expandAliases', () => {
57
+ test('adds the current scope for a legacy Zone read scope', () => {
58
+ expect(
59
+ Scope.expandAliases(['data:read', 'zone:1424310001:read', 'zone:1424310001:write']),
60
+ ).toEqual([
61
+ 'data:read',
62
+ 'zone:1424310001:read',
63
+ 'zone:1424310001:write',
64
+ 'zone:1424310003:read',
65
+ ])
66
+ })
67
+
68
+ test('does not duplicate an existing current scope', () => {
69
+ expect(Scope.expandAliases(['zone:1424310001:read', 'zone:1424310003:read'])).toEqual([
70
+ 'zone:1424310001:read',
71
+ 'zone:1424310003:read',
72
+ ])
73
+ })
74
+ })
package/src/Scope.ts CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  import * as z from 'zod/mini'
8
8
 
9
+ import * as Schema from './internal/Schema.js'
10
+
9
11
  /**
10
12
  * Wildcard scope. A key whose `scopes` contains this value satisfies every
11
13
  * required scope, so it never needs re-provisioning when new scopes are added.
@@ -154,6 +156,20 @@ export function includes(catalog: Catalog, scope: string): scope is Id {
154
156
  return schema.Zone.safeParse(scope).success || catalog.some((entry) => entry.scope === scope)
155
157
  }
156
158
 
159
+ /** Adds current read scopes granted by legacy Zone read scopes. */
160
+ export function expandAliases(scopes: readonly string[]): readonly string[] {
161
+ const aliases = new Set<string>()
162
+ for (const scope of scopes) {
163
+ const match = /^zone:([1-9]\d*):read$/.exec(scope)
164
+ if (!match) continue
165
+ const chainId = Number(match[1])
166
+ const resolved = Schema.resolveChainId(chainId)
167
+ if (resolved !== chainId) aliases.add(`zone:${resolved}:read`)
168
+ }
169
+ for (const scope of scopes) aliases.delete(scope)
170
+ return aliases.size ? [...scopes, ...aliases] : scopes
171
+ }
172
+
157
173
  /** Returns the self-issuable identifiers in a scope catalog. */
158
174
  export function selfServeFrom(catalog: Catalog): readonly Id[] {
159
175
  return catalog
@@ -299,6 +299,81 @@ describe('DELETE /api-keys/:id', () => {
299
299
  })
300
300
  })
301
301
 
302
+ describe('POST /api-keys/:id/rotate', () => {
303
+ test('mints a replacement with the source access and attribution', async () => {
304
+ const { client, store } = await setup()
305
+ const { record: source, token: sourceToken } = await core_ApiKeys.mint(store, {
306
+ allowedIps: ['203.0.113.0/24'],
307
+ billingActive: true,
308
+ createdBy: 'customer@example.com',
309
+ environment: 'sandbox',
310
+ expiresAt: '2100-01-01T00:00:00.000Z',
311
+ name: 'Privy Zones',
312
+ orgId: 'org_privy',
313
+ projectId: 'prj_privy',
314
+ rateLimits: { '*': { limit: 10_000, period: 'minute' } },
315
+ scopes: ['zone:421700001:read', 'zone:421700001:write'],
316
+ })
317
+
318
+ const response = await client['api-keys'][':id'].rotate.$post({
319
+ param: { id: source.id },
320
+ })
321
+ const body = await TestApp.json(response, ApiKeys.schema.rotateApiKey.Response)
322
+
323
+ expect(response.status).toBe(200)
324
+ expect(body).toMatchObject({
325
+ allowedIps: source.allowedIps,
326
+ billingActive: source.billingActive,
327
+ createdBy: TestAdmin.identity.email,
328
+ environment: source.environment,
329
+ expiresAt: source.expiresAt,
330
+ name: source.name,
331
+ orgId: source.orgId,
332
+ projectId: source.projectId,
333
+ rateLimits: source.rateLimits,
334
+ scopes: source.scopes,
335
+ })
336
+ expect(body.id).not.toBe(source.id)
337
+ expect(body.token).not.toBe(sourceToken)
338
+ expect(await core_ApiKeys.resolve(store, sourceToken)).not.toBeNull()
339
+ expect(await core_ApiKeys.resolve(store, body.token)).toMatchObject({
340
+ id: body.id,
341
+ scopes: source.scopes,
342
+ })
343
+ })
344
+
345
+ test('unknown id → 404', async () => {
346
+ const { client } = await setup()
347
+ const response = await client['api-keys'][':id'].rotate.$post({
348
+ param: { id: 'key_missing' },
349
+ })
350
+
351
+ expect(response.status).toMatchInlineSnapshot(`404`)
352
+ })
353
+
354
+ test('keeps a rotated one-off key assignable to an organization', async () => {
355
+ const { client, db, store } = await setup()
356
+ const organization = await Organizations.create(db, { name: 'Tempo' })
357
+ const { record: source } = await core_ApiKeys.mint(store, {
358
+ name: 'One-off',
359
+ scopes: ['data:read'],
360
+ })
361
+
362
+ const rotateResponse = await client['api-keys'][':id'].rotate.$post({
363
+ param: { id: source.id },
364
+ })
365
+ const replacement = await TestApp.json(rotateResponse, ApiKeys.schema.rotateApiKey.Response)
366
+ expect(replacement.orgId).toBe(replacement.id)
367
+
368
+ const updateResponse = await client['api-keys'][':id'].$patch({
369
+ json: { orgId: organization.id },
370
+ param: { id: replacement.id },
371
+ })
372
+ expect(updateResponse.status).toBe(200)
373
+ expect((await core_ApiKeys.get(store, replacement.id))?.orgId).toBe(organization.id)
374
+ })
375
+ })
376
+
302
377
  describe('PATCH /api-keys/:id', () => {
303
378
  test('preserves the allowlist when omitted', async () => {
304
379
  const { client, store } = TestAdmin.setup()
@@ -173,6 +173,24 @@ export namespace schema {
173
173
  .object({ id: z.string().check(z.describe('Revoked key id.')) })
174
174
  .check(z.describe('API-key revocation result.'))
175
175
  }
176
+
177
+ /** Schemas for the rotateApiKey operation. */
178
+ export namespace rotateApiKey {
179
+ /** Path parameters for rotating a key. */
180
+ export const Params = z
181
+ .object({
182
+ id: z
183
+ .string()
184
+ .check(
185
+ z.describe('Stable id of the key to rotate.'),
186
+ z.meta({ examples: ['key_1a2b3c4d5e6f7g8h9j0k1m2n'] }),
187
+ ),
188
+ })
189
+ .check(z.describe('Path parameters for rotating an API key.'))
190
+
191
+ /** Response: the replacement record plus its one-time plaintext token. */
192
+ export const Response = createApiKey.Response
193
+ }
176
194
  }
177
195
 
178
196
  /**
@@ -183,6 +201,7 @@ export namespace schema {
183
201
  * - `GET /` lists key records (metadata only), optionally filtered by `orgId`.
184
202
  * - `DELETE /:id` revokes a key by id (`404` if absent).
185
203
  * - `PATCH /:id` updates a key's allowlist, name, or project, or assigns its first organization.
204
+ * - `POST /:id/rotate` mints a replacement with the source key's access and attribution.
186
205
  * - `POST /backfill` rebuilds the id and org index entries for every record.
187
206
  */
188
207
  export function apiKeys() {
@@ -315,6 +334,51 @@ export function apiKeys() {
315
334
  return c.json(Response.validated(schema.revokeApiKey.Response, { id }))
316
335
  },
317
336
  )
337
+ .post(
338
+ '/:id/rotate',
339
+ OpenApi.validate('param', schema.rotateApiKey.Params, {
340
+ code: 'param_invalid',
341
+ message: 'Invalid path parameters',
342
+ }),
343
+ OpenApi.describeRoute({
344
+ description: 'Create a replacement that preserves the source key access and attribution. The source key remains active.', // prettier-ignore
345
+ operationId: 'rotateAdminApiKey',
346
+ responses: OpenApi.responses({
347
+ errors: { 404: 'API key not found.' },
348
+ success: {
349
+ description: 'Replacement key (includes the one-time token).',
350
+ schema: schema.rotateApiKey.Response,
351
+ },
352
+ }),
353
+ summary: 'Rotate API key',
354
+ tags: ['API keys'],
355
+ }),
356
+ async (c) => {
357
+ const { id } = c.req.valid('param')
358
+ const store = c.get('kv').store
359
+ const source = await ApiKeys.get(store, id)
360
+ if (!source)
361
+ return Response.error(c, {
362
+ code: 'not_found',
363
+ message: 'API key not found.',
364
+ status: 404,
365
+ })
366
+
367
+ const { record, token } = await ApiKeys.mint(store, {
368
+ allowedIps: source.allowedIps,
369
+ createdBy: c.get('identity')!.email,
370
+ environment: source.environment,
371
+ scopes: source.scopes,
372
+ ...(source.billingActive === undefined ? {} : { billingActive: source.billingActive }),
373
+ ...(source.expiresAt === undefined ? {} : { expiresAt: source.expiresAt }),
374
+ ...(source.name === undefined ? {} : { name: source.name }),
375
+ ...(source.orgId === source.id ? {} : { orgId: source.orgId }),
376
+ ...(source.projectId === undefined ? {} : { projectId: source.projectId }),
377
+ ...(source.rateLimits === undefined ? {} : { rateLimits: source.rateLimits }),
378
+ })
379
+ return c.json(Response.validated(schema.rotateApiKey.Response, { ...record, token }), 200)
380
+ },
381
+ )
318
382
  .patch(
319
383
  '/:id',
320
384
  OpenApi.validate('param', schema.updateApiKey.Params, {
@@ -204,7 +204,7 @@ describe('putVerifiedEarnVault', () => {
204
204
  expect(await response.json()).toMatchObject({
205
205
  error: {
206
206
  code: 'body_invalid',
207
- details: [{ message: 'Zone 1424310001 is configured more than once.' }],
207
+ details: [{ message: 'Zone 1424310003 is configured more than once.' }],
208
208
  },
209
209
  })
210
210
  },
@@ -94,7 +94,7 @@ export namespace schema {
94
94
  .check(z.describe('Custom vault slug, generated from the label when omitted.')),
95
95
  vaultAddress: Schema.Address.check(
96
96
  z.describe('Earn vault contract address.'),
97
- z.meta({ examples: ['0xf4ae63687d6753a78e7f551d2eda1d0d31a5ea3a'] }),
97
+ z.meta({ examples: ['0x140a09b7c5826d34dfa38ea7bfde1c7e478c4408'] }),
98
98
  ),
99
99
  zones: z
100
100
  .optional(z.array(EarnVaults.schema.ZoneRoute))
@@ -93,6 +93,25 @@ export namespace update {
93
93
  }
94
94
  }
95
95
 
96
+ /** Rotates an API key while preserving its access and attribution. */
97
+ export namespace rotate {
98
+ export async function fn(id: string) {
99
+ const response = await client['api-keys'][':id'].rotate.$post({ param: { id } })
100
+ if (!response.ok)
101
+ throw new Error(
102
+ await Response.errorMessage(response, { fallback: 'Failed to rotate the API key.' }),
103
+ )
104
+ return response.json()
105
+ }
106
+ export function useMutation() {
107
+ const queryClient = ReactQuery.useQueryClient()
108
+ return ReactQuery.useMutation({
109
+ mutationFn: fn,
110
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: get.baseKey }),
111
+ })
112
+ }
113
+ }
114
+
96
115
  /** Revoke an API key by id. */
97
116
  export namespace revoke {
98
117
  export async function fn(id: string) {
@@ -8,6 +8,7 @@ import {
8
8
  Copy as CopyIcon,
9
9
  Pencil,
10
10
  Plus,
11
+ RefreshCw,
11
12
  Search,
12
13
  Trash2,
13
14
  TriangleAlert,
@@ -207,13 +208,13 @@ function RouteComponent() {
207
208
  )
208
209
  }
209
210
 
210
- function TokenBanner(props: { onDismiss: () => void; token: string }) {
211
+ function TokenBanner(props: { onDismiss: () => void; title?: string; token: string }) {
211
212
  const { copy, notifying } = useCopy()
212
213
  return (
213
214
  <Card className="flex flex-col gap-3 p-4">
214
215
  <div className="flex items-start justify-between gap-3">
215
216
  <div className="flex flex-col gap-1">
216
- <span className="text-label-14 text-foreground">Key provisioned</span>
217
+ <span className="text-label-14 text-foreground">{props.title ?? 'Key provisioned'}</span>
217
218
  <span className="text-label-12 text-foreground-secondary">
218
219
  Copy this token now, it won't be shown again.
219
220
  </span>
@@ -732,6 +733,7 @@ function KeyRow(props: { currentUser?: string | undefined; record: ApiKeys.ApiKe
732
733
  </dl>
733
734
  <div className="flex justify-end gap-2 border-t border-border px-5 py-3">
734
735
  <EditDialog record={record} />
736
+ <RotateDialog record={record} />
735
737
  <RevokeDialog currentUser={currentUser} record={record} />
736
738
  </div>
737
739
  </Rows.Panel>
@@ -812,6 +814,91 @@ declare namespace EditDialog {
812
814
  }
813
815
  }
814
816
 
817
+ function RotateDialog(options: RotateDialog.Props) {
818
+ const { record } = options
819
+ const [open, setOpen] = useState(false)
820
+ const rotate = ApiKeys.rotate.useMutation()
821
+
822
+ return (
823
+ <Dialog.Root
824
+ onOpenChange={(value) => {
825
+ if (!value && rotate.isPending) return
826
+ setOpen(value)
827
+ if (!value) rotate.reset()
828
+ }}
829
+ open={open}
830
+ >
831
+ <Dialog.Trigger
832
+ render={
833
+ <Button size="small">
834
+ <RefreshCw className="size-4" />
835
+ Rotate key
836
+ </Button>
837
+ }
838
+ />
839
+ <Dialog.Popup>
840
+ {rotate.data ? (
841
+ <>
842
+ <Dialog.Title>Key rotated</Dialog.Title>
843
+ <Dialog.Description>
844
+ Copy the replacement token now. The source key remains active until it is revoked.
845
+ </Dialog.Description>
846
+ <div className="mt-5">
847
+ <TokenBanner
848
+ onDismiss={() => setOpen(false)}
849
+ title="Replacement key provisioned"
850
+ token={rotate.data.token}
851
+ />
852
+ </div>
853
+ </>
854
+ ) : (
855
+ <>
856
+ <Dialog.Title>Rotate {record.name ?? 'API key'}?</Dialog.Title>
857
+ <Dialog.Description>
858
+ This creates a replacement with the same scopes, ownership, limits, and restrictions.
859
+ The source key remains active.
860
+ </Dialog.Description>
861
+ <dl className="mt-5 grid grid-cols-2 gap-4 border border-border bg-secondary p-4">
862
+ <Detail className="col-span-2" label="Key ID">
863
+ <code className="break-all">{record.id}</code>
864
+ </Detail>
865
+ <Detail label="Environment">{record.environment}</Detail>
866
+ <Detail label="Organization">{organization(record)}</Detail>
867
+ <Detail label="Project">{project(record)}</Detail>
868
+ <Detail label="Scopes">{record.scopes.length}</Detail>
869
+ </dl>
870
+ {rotate.error ? (
871
+ <p className="mt-3 text-copy-13 text-negative">{rotate.error.message}</p>
872
+ ) : null}
873
+ <div className="mt-6 flex justify-end gap-2">
874
+ <Dialog.Close
875
+ render={
876
+ <Button disabled={rotate.isPending} type="button">
877
+ Cancel
878
+ </Button>
879
+ }
880
+ />
881
+ <Button
882
+ loading={rotate.isPending}
883
+ onClick={() => rotate.mutate(record.id)}
884
+ variant="primary"
885
+ >
886
+ Rotate key
887
+ </Button>
888
+ </div>
889
+ </>
890
+ )}
891
+ </Dialog.Popup>
892
+ </Dialog.Root>
893
+ )
894
+ }
895
+
896
+ declare namespace RotateDialog {
897
+ type Props = {
898
+ record: ApiKeys.ApiKey
899
+ }
900
+ }
901
+
815
902
  function RevokeDialog(props: { currentUser?: string | undefined; record: ApiKeys.ApiKey }) {
816
903
  const { currentUser, record } = props
817
904
  const [confirmation, setConfirmation] = useState('')