tapimo 0.19.0 → 0.20.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 (494) hide show
  1. package/README.md +13 -5
  2. package/dist/ApiKey.d.ts +2 -2
  3. package/dist/ApiKey.d.ts.map +1 -1
  4. package/dist/ApiKey.js +2 -2
  5. package/dist/ApiKey.js.map +1 -1
  6. package/dist/ApiKeys.d.ts +85 -11
  7. package/dist/ApiKeys.d.ts.map +1 -1
  8. package/dist/ApiKeys.js +218 -56
  9. package/dist/ApiKeys.js.map +1 -1
  10. package/dist/App.d.ts +12543 -13912
  11. package/dist/App.d.ts.map +1 -1
  12. package/dist/App.js +4 -5
  13. package/dist/App.js.map +1 -1
  14. package/dist/Scope.d.ts +12 -20
  15. package/dist/Scope.d.ts.map +1 -1
  16. package/dist/Scope.js +20 -36
  17. package/dist/Scope.js.map +1 -1
  18. package/dist/admin/App.d.ts +871 -828
  19. package/dist/admin/App.d.ts.map +1 -1
  20. package/dist/admin/App.js +14 -26
  21. package/dist/admin/App.js.map +1 -1
  22. package/dist/admin/apps/api-keys.d.ts +93 -186
  23. package/dist/admin/apps/api-keys.d.ts.map +1 -1
  24. package/dist/admin/apps/api-keys.js +3 -5
  25. package/dist/admin/apps/api-keys.js.map +1 -1
  26. package/dist/admin/apps/audit-logs.d.ts +18 -18
  27. package/dist/admin/apps/audit-logs.js +1 -1
  28. package/dist/admin/apps/audit-logs.js.map +1 -1
  29. package/dist/admin/apps/earn-vaults.d.ts +27 -27
  30. package/dist/admin/apps/earn-vaults.d.ts.map +1 -1
  31. package/dist/admin/apps/earn-vaults.js +11 -5
  32. package/dist/admin/apps/earn-vaults.js.map +1 -1
  33. package/dist/admin/apps/organizations.d.ts +17 -17
  34. package/dist/admin/apps/prop-amms.d.ts +4 -4
  35. package/dist/admin/apps/routes-support.js +1 -38
  36. package/dist/admin/apps/routes-support.js.map +1 -1
  37. package/dist/admin/apps/routes.d.ts +153 -153
  38. package/dist/admin/apps/sentinel.d.ts +4 -4
  39. package/dist/admin/apps/verified-tokens.d.ts +24 -24
  40. package/dist/admin/apps/zone-delegation-requests.d.ts +332 -0
  41. package/dist/admin/apps/zone-delegation-requests.d.ts.map +1 -0
  42. package/dist/admin/apps/zone-delegation-requests.js +200 -0
  43. package/dist/admin/apps/zone-delegation-requests.js.map +1 -0
  44. package/dist/admin/apps/zone-delegations.d.ts +512 -52
  45. package/dist/admin/apps/zone-delegations.d.ts.map +1 -1
  46. package/dist/admin/apps/zone-delegations.js +134 -44
  47. package/dist/admin/apps/zone-delegations.js.map +1 -1
  48. package/dist/admin/index.d.ts +1 -1
  49. package/dist/admin/index.d.ts.map +1 -1
  50. package/dist/admin/index.js +1 -1
  51. package/dist/admin/index.js.map +1 -1
  52. package/dist/admin/ui.generated.d.ts +1 -1
  53. package/dist/admin/ui.generated.d.ts.map +1 -1
  54. package/dist/admin/ui.generated.js +341 -397
  55. package/dist/admin/ui.generated.js.map +1 -1
  56. package/dist/analytics/tables/requestEvents.d.ts.map +1 -1
  57. package/dist/analytics/tables/requestEvents.js +4 -2
  58. package/dist/analytics/tables/requestEvents.js.map +1 -1
  59. package/dist/apps/data/App.d.ts +11932 -13214
  60. package/dist/apps/data/App.d.ts.map +1 -1
  61. package/dist/apps/data/App.js +32 -13
  62. package/dist/apps/data/App.js.map +1 -1
  63. package/dist/apps/data/ZoneSelection.d.ts +1 -1
  64. package/dist/apps/data/ZoneSelection.d.ts.map +1 -1
  65. package/dist/apps/data/ZoneSelection.js +2 -2
  66. package/dist/apps/data/ZoneSelection.js.map +1 -1
  67. package/dist/apps/data/routes/activities.d.ts +60 -27
  68. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  69. package/dist/apps/data/routes/activities.js +91 -16
  70. package/dist/apps/data/routes/activities.js.map +1 -1
  71. package/dist/apps/data/routes/addresses.d.ts +893 -875
  72. package/dist/apps/data/routes/addresses.d.ts.map +1 -1
  73. package/dist/apps/data/routes/balances.d.ts +36 -36
  74. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  75. package/dist/apps/data/routes/balances.js +3 -1
  76. package/dist/apps/data/routes/balances.js.map +1 -1
  77. package/dist/apps/data/routes/blocks.d.ts +40 -40
  78. package/dist/apps/data/routes/blocks.d.ts.map +1 -1
  79. package/dist/apps/data/routes/blocks.js +3 -7
  80. package/dist/apps/data/routes/blocks.js.map +1 -1
  81. package/dist/apps/data/routes/coingecko.d.ts +236 -236
  82. package/dist/apps/data/routes/earn-transactions.d.ts +14 -14
  83. package/dist/apps/data/routes/earn.d.ts +261 -1445
  84. package/dist/apps/data/routes/earn.d.ts.map +1 -1
  85. package/dist/apps/data/routes/earn.js +64 -490
  86. package/dist/apps/data/routes/earn.js.map +1 -1
  87. package/dist/apps/data/routes/exchanges.d.ts +144 -144
  88. package/dist/apps/data/routes/exchanges.d.ts.map +1 -1
  89. package/dist/apps/data/routes/exchanges.js +5 -15
  90. package/dist/apps/data/routes/exchanges.js.map +1 -1
  91. package/dist/apps/data/routes/fee-amm.d.ts.map +1 -1
  92. package/dist/apps/data/routes/fee-amm.js +3 -9
  93. package/dist/apps/data/routes/fee-amm.js.map +1 -1
  94. package/dist/apps/data/routes/indexer.d.ts +13 -13
  95. package/dist/apps/data/routes/receipts.d.ts +53 -53
  96. package/dist/apps/data/routes/receipts.js +1 -1
  97. package/dist/apps/data/routes/receipts.js.map +1 -1
  98. package/dist/apps/data/routes/rpc.d.ts +52 -0
  99. package/dist/apps/data/routes/rpc.d.ts.map +1 -1
  100. package/dist/apps/data/routes/rpc.js +19 -18
  101. package/dist/apps/data/routes/rpc.js.map +1 -1
  102. package/dist/apps/data/routes/tokenlist.d.ts +14 -14
  103. package/dist/apps/data/routes/tokenlist.d.ts.map +1 -1
  104. package/dist/apps/data/routes/tokenlist.js +1 -3
  105. package/dist/apps/data/routes/tokenlist.js.map +1 -1
  106. package/dist/apps/data/routes/tokens.d.ts.map +1 -1
  107. package/dist/apps/data/routes/tokens.js +7 -5
  108. package/dist/apps/data/routes/tokens.js.map +1 -1
  109. package/dist/apps/data/routes/transactions.d.ts +281 -281
  110. package/dist/apps/data/routes/transactions.js +3 -3
  111. package/dist/apps/data/routes/transactions.js.map +1 -1
  112. package/dist/apps/data/routes/transfers.d.ts +12 -12
  113. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  114. package/dist/apps/data/routes/transfers.js +1 -3
  115. package/dist/apps/data/routes/transfers.js.map +1 -1
  116. package/dist/apps/data/routes/valuation.d.ts +30 -30
  117. package/dist/apps/data/routes/valuation.d.ts.map +1 -1
  118. package/dist/apps/data/routes/valuation.js +1 -3
  119. package/dist/apps/data/routes/valuation.js.map +1 -1
  120. package/dist/apps/data/routes/verified-tokens.d.ts +86 -86
  121. package/dist/apps/data/routes/webhooks.d.ts +102 -102
  122. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  123. package/dist/apps/data/routes/webhooks.js +9 -23
  124. package/dist/apps/data/routes/webhooks.js.map +1 -1
  125. package/dist/apps/data/routes/zone-deposits.d.ts +16 -16
  126. package/dist/apps/data/routes/zone-withdrawals.d.ts +31 -31
  127. package/dist/apps/data/routes/zones.d.ts +28 -28
  128. package/dist/apps/management/App.d.ts +18 -102
  129. package/dist/apps/management/App.d.ts.map +1 -1
  130. package/dist/apps/management/App.js +1 -1
  131. package/dist/apps/management/App.js.map +1 -1
  132. package/dist/apps/management/routes/api-keys.d.ts +253 -339
  133. package/dist/apps/management/routes/api-keys.d.ts.map +1 -1
  134. package/dist/apps/management/routes/api-keys.js +25 -11
  135. package/dist/apps/management/routes/api-keys.js.map +1 -1
  136. package/dist/apps/management/routes/billing.d.ts +92 -92
  137. package/dist/apps/management/routes/billing.d.ts.map +1 -1
  138. package/dist/apps/management/routes/billing.js +5 -13
  139. package/dist/apps/management/routes/billing.js.map +1 -1
  140. package/dist/apps/management/routes/cli.d.ts.map +1 -1
  141. package/dist/apps/management/routes/cli.js +3 -9
  142. package/dist/apps/management/routes/cli.js.map +1 -1
  143. package/dist/apps/management/routes/faucet.d.ts +16 -16
  144. package/dist/apps/management/routes/fee-payer-usage.d.ts +38 -38
  145. package/dist/apps/management/routes/fee-payer-usage.d.ts.map +1 -1
  146. package/dist/apps/management/routes/fee-payer-usage.js +2 -4
  147. package/dist/apps/management/routes/fee-payer-usage.js.map +1 -1
  148. package/dist/apps/management/routes/invitations.d.ts +52 -52
  149. package/dist/apps/management/routes/invitations.d.ts.map +1 -1
  150. package/dist/apps/management/routes/invitations.js +4 -12
  151. package/dist/apps/management/routes/invitations.js.map +1 -1
  152. package/dist/apps/management/routes/invite-links.d.ts.map +1 -1
  153. package/dist/apps/management/routes/invite-links.js +6 -14
  154. package/dist/apps/management/routes/invite-links.js.map +1 -1
  155. package/dist/apps/management/routes/me.d.ts +6 -6
  156. package/dist/apps/management/routes/me.d.ts.map +1 -1
  157. package/dist/apps/management/routes/me.js +3 -7
  158. package/dist/apps/management/routes/me.js.map +1 -1
  159. package/dist/apps/management/routes/members.d.ts +84 -84
  160. package/dist/apps/management/routes/members.d.ts.map +1 -1
  161. package/dist/apps/management/routes/members.js +2 -4
  162. package/dist/apps/management/routes/members.js.map +1 -1
  163. package/dist/apps/management/routes/orgs.d.ts +44 -44
  164. package/dist/apps/management/routes/orgs.d.ts.map +1 -1
  165. package/dist/apps/management/routes/orgs.js +2 -6
  166. package/dist/apps/management/routes/orgs.js.map +1 -1
  167. package/dist/apps/management/routes/projects.d.ts.map +1 -1
  168. package/dist/apps/management/routes/projects.js +2 -6
  169. package/dist/apps/management/routes/projects.js.map +1 -1
  170. package/dist/apps/management/routes/route-usage.d.ts +34 -34
  171. package/dist/apps/management/routes/route-usage.d.ts.map +1 -1
  172. package/dist/apps/management/routes/route-usage.js +2 -4
  173. package/dist/apps/management/routes/route-usage.js.map +1 -1
  174. package/dist/apps/management/routes/routes-access-requests.d.ts +120 -91
  175. package/dist/apps/management/routes/routes-access-requests.d.ts.map +1 -1
  176. package/dist/apps/management/routes/routes-access-requests.js +49 -9
  177. package/dist/apps/management/routes/routes-access-requests.js.map +1 -1
  178. package/dist/apps/management/routes/usage.d.ts.map +1 -1
  179. package/dist/apps/management/routes/usage.js +8 -16
  180. package/dist/apps/management/routes/usage.js.map +1 -1
  181. package/dist/apps/management/routes/verified-token-requests.d.ts +39 -39
  182. package/dist/apps/management/routes/verified-token-requests.d.ts.map +1 -1
  183. package/dist/apps/management/routes/verified-token-requests.js +3 -7
  184. package/dist/apps/management/routes/verified-token-requests.js.map +1 -1
  185. package/dist/apps/management/routes/webhooks.d.ts +2089 -2089
  186. package/dist/apps/management/routes/zone-delegations.d.ts +14 -17
  187. package/dist/apps/management/routes/zone-delegations.d.ts.map +1 -1
  188. package/dist/apps/management/routes/zone-delegations.js +1 -6
  189. package/dist/apps/management/routes/zone-delegations.js.map +1 -1
  190. package/dist/apps/mpp/App.d.ts +44 -44
  191. package/dist/apps/mpp/App.js +3 -3
  192. package/dist/apps/mpp/App.js.map +1 -1
  193. package/dist/apps/mpp/internal/Adapter.js +1 -1
  194. package/dist/apps/mpp/internal/Adapter.js.map +1 -1
  195. package/dist/apps/mpp/internal/Error.d.ts.map +1 -1
  196. package/dist/apps/mpp/internal/Error.js +1 -0
  197. package/dist/apps/mpp/internal/Error.js.map +1 -1
  198. package/dist/apps/mpp/internal/Policy.d.ts +5 -1
  199. package/dist/apps/mpp/internal/Policy.d.ts.map +1 -1
  200. package/dist/apps/mpp/internal/Policy.js +35 -13
  201. package/dist/apps/mpp/internal/Policy.js.map +1 -1
  202. package/dist/apps/relay/App.d.ts +2 -2
  203. package/dist/apps/relay/App.d.ts.map +1 -1
  204. package/dist/apps/relay/App.js +25 -16
  205. package/dist/apps/relay/App.js.map +1 -1
  206. package/dist/apps/routes/App.d.ts +311 -311
  207. package/dist/apps/routes/routes/deposit-addresses.d.ts +1099 -1099
  208. package/dist/apps/routes/routes/list.d.ts +74 -74
  209. package/dist/apps/routes/routes/transfers.d.ts +94 -94
  210. package/dist/cli/ApiKeys.d.ts +19 -26
  211. package/dist/cli/ApiKeys.d.ts.map +1 -1
  212. package/dist/db/Db.d.ts +3 -3
  213. package/dist/db/Db.d.ts.map +1 -1
  214. package/dist/db/Db.js +27 -0
  215. package/dist/db/Db.js.map +1 -1
  216. package/dist/db/Schema.d.ts +28 -62
  217. package/dist/db/Schema.d.ts.map +1 -1
  218. package/dist/db/Schema.js +18 -18
  219. package/dist/db/Schema.js.map +1 -1
  220. package/dist/db/tables/rewardRuns.d.ts +21 -0
  221. package/dist/db/tables/rewardRuns.d.ts.map +1 -1
  222. package/dist/db/tables/rewardRuns.js +19 -0
  223. package/dist/db/tables/rewardRuns.js.map +1 -1
  224. package/dist/db/tables/routesAccessRequests.d.ts +13 -0
  225. package/dist/db/tables/routesAccessRequests.d.ts.map +1 -1
  226. package/dist/db/tables/routesAccessRequests.js +12 -0
  227. package/dist/db/tables/routesAccessRequests.js.map +1 -1
  228. package/dist/db/tables/zoneDelegationRequests.d.ts +165 -0
  229. package/dist/db/tables/zoneDelegationRequests.d.ts.map +1 -0
  230. package/dist/db/tables/{zoneKeyRequests.js → zoneDelegationRequests.js} +43 -42
  231. package/dist/db/tables/zoneDelegationRequests.js.map +1 -0
  232. package/dist/db/tables/zoneDelegations.d.ts +11 -5
  233. package/dist/db/tables/zoneDelegations.d.ts.map +1 -1
  234. package/dist/db/tables/zoneDelegations.js +21 -3
  235. package/dist/db/tables/zoneDelegations.js.map +1 -1
  236. package/dist/handlers/internal/accounts.d.ts +7 -0
  237. package/dist/handlers/internal/accounts.d.ts.map +1 -0
  238. package/dist/handlers/internal/{multisig.js → accounts.js} +10 -10
  239. package/dist/handlers/internal/{multisig.js.map → accounts.js.map} +1 -1
  240. package/dist/handlers/internal/sponsorship.js +17 -17
  241. package/dist/handlers/internal/sponsorship.js.map +1 -1
  242. package/dist/handlers/relay.d.ts +6 -6
  243. package/dist/handlers/relay.d.ts.map +1 -1
  244. package/dist/handlers/relay.js +15 -15
  245. package/dist/handlers/relay.js.map +1 -1
  246. package/dist/internal/Auth.d.ts.map +1 -1
  247. package/dist/internal/Auth.js +4 -8
  248. package/dist/internal/Auth.js.map +1 -1
  249. package/dist/internal/EarnRates.d.ts.map +1 -1
  250. package/dist/internal/EarnRates.js +1 -3
  251. package/dist/internal/EarnRates.js.map +1 -1
  252. package/dist/internal/EarnVaults.js +2 -2
  253. package/dist/internal/Log.d.ts +2 -4
  254. package/dist/internal/Log.d.ts.map +1 -1
  255. package/dist/internal/Log.js +0 -3
  256. package/dist/internal/Log.js.map +1 -1
  257. package/dist/internal/Response.d.ts +1 -1
  258. package/dist/internal/Response.d.ts.map +1 -1
  259. package/dist/internal/Response.js +0 -3
  260. package/dist/internal/Response.js.map +1 -1
  261. package/dist/internal/Schema.d.ts +11 -0
  262. package/dist/internal/Schema.d.ts.map +1 -1
  263. package/dist/internal/Schema.js +24 -1
  264. package/dist/internal/Schema.js.map +1 -1
  265. package/dist/internal/ZoneAccess.d.ts +70 -35
  266. package/dist/internal/ZoneAccess.d.ts.map +1 -1
  267. package/dist/internal/ZoneAccess.js +312 -131
  268. package/dist/internal/ZoneAccess.js.map +1 -1
  269. package/dist/internal/ZoneDelegation.d.ts +7 -19
  270. package/dist/internal/ZoneDelegation.d.ts.map +1 -1
  271. package/dist/internal/ZoneDelegation.js +8 -29
  272. package/dist/internal/ZoneDelegation.js.map +1 -1
  273. package/dist/internal/{ZoneKeyApproval.d.ts → ZoneDelegationApproval.d.ts} +3 -5
  274. package/dist/internal/ZoneDelegationApproval.d.ts.map +1 -0
  275. package/dist/internal/ZoneDelegationApproval.js +16 -0
  276. package/dist/internal/ZoneDelegationApproval.js.map +1 -0
  277. package/dist/internal/ZoneKeyRewrite.d.ts +98 -0
  278. package/dist/internal/ZoneKeyRewrite.d.ts.map +1 -0
  279. package/dist/internal/ZoneKeyRewrite.js +151 -0
  280. package/dist/internal/ZoneKeyRewrite.js.map +1 -0
  281. package/dist/internal/rewards/Campaigns.d.ts +2 -0
  282. package/dist/internal/rewards/Campaigns.d.ts.map +1 -1
  283. package/dist/internal/rewards/Campaigns.js.map +1 -1
  284. package/dist/internal/rewards/Distributions.d.ts.map +1 -1
  285. package/dist/internal/rewards/Distributions.js +2 -6
  286. package/dist/internal/rewards/Distributions.js.map +1 -1
  287. package/dist/internal/rewards/Monitor.d.ts +4 -1
  288. package/dist/internal/rewards/Monitor.d.ts.map +1 -1
  289. package/dist/internal/rewards/Monitor.js +19 -2
  290. package/dist/internal/rewards/Monitor.js.map +1 -1
  291. package/dist/internal/rewards/Runner.d.ts.map +1 -1
  292. package/dist/internal/rewards/Runner.js +9 -1
  293. package/dist/internal/rewards/Runner.js.map +1 -1
  294. package/dist/internal/routes/Action.d.ts.map +1 -1
  295. package/dist/internal/routes/Action.js +2 -3
  296. package/dist/internal/routes/Action.js.map +1 -1
  297. package/dist/internal/routes/Deposit.d.ts.map +1 -1
  298. package/dist/internal/routes/Deposit.js +2 -6
  299. package/dist/internal/routes/Deposit.js.map +1 -1
  300. package/dist/internal/routes/DepositAddress.d.ts.map +1 -1
  301. package/dist/internal/routes/DepositAddress.js +4 -10
  302. package/dist/internal/routes/DepositAddress.js.map +1 -1
  303. package/dist/internal/routes/Policy.d.ts.map +1 -1
  304. package/dist/internal/routes/Policy.js +2 -1
  305. package/dist/internal/routes/Policy.js.map +1 -1
  306. package/dist/internal/routes/Provider.js +8 -8
  307. package/dist/internal/routes/Provider.js.map +1 -1
  308. package/dist/internal/routes/Reconciliation.d.ts.map +1 -1
  309. package/dist/internal/routes/Reconciliation.js +3 -2
  310. package/dist/internal/routes/Reconciliation.js.map +1 -1
  311. package/dist/internal/routes/Transfer.d.ts.map +1 -1
  312. package/dist/internal/routes/Transfer.js +4 -12
  313. package/dist/internal/routes/Transfer.js.map +1 -1
  314. package/dist/internal/routes/UniversalDepositAddress.d.ts.map +1 -1
  315. package/dist/internal/routes/UniversalDepositAddress.js +3 -6
  316. package/dist/internal/routes/UniversalDepositAddress.js.map +1 -1
  317. package/dist/internal/routes/providers/SquidOrder.d.ts.map +1 -1
  318. package/dist/internal/routes/providers/SquidOrder.js +3 -2
  319. package/dist/internal/routes/providers/SquidOrder.js.map +1 -1
  320. package/dist/internal/routes/providers/relay.d.ts.map +1 -1
  321. package/dist/internal/routes/providers/relay.js +4 -3
  322. package/dist/internal/routes/providers/relay.js.map +1 -1
  323. package/package.json +20 -5
  324. package/src/ApiKey.ts +2 -2
  325. package/src/ApiKeys.test.ts +284 -51
  326. package/src/ApiKeys.ts +297 -64
  327. package/src/App.ts +4 -5
  328. package/src/Client.test-d.ts +33 -126
  329. package/src/Scope.test.ts +26 -45
  330. package/src/Scope.ts +24 -37
  331. package/src/admin/App.test.ts +51 -4
  332. package/src/admin/App.ts +25 -33
  333. package/src/admin/Mcp.test.ts +2 -1
  334. package/src/admin/apps/api-keys.test.ts +90 -20
  335. package/src/admin/apps/api-keys.ts +5 -8
  336. package/src/admin/apps/audit-logs.ts +1 -1
  337. package/src/admin/apps/earn-vaults.test.ts +19 -19
  338. package/src/admin/apps/earn-vaults.ts +11 -5
  339. package/src/admin/apps/routes-support.test.ts +254 -3
  340. package/src/admin/apps/routes-support.ts +1 -44
  341. package/src/admin/apps/scopes.test.ts +3 -3
  342. package/src/admin/apps/zone-delegation-requests.test.ts +246 -0
  343. package/src/admin/apps/zone-delegation-requests.ts +240 -0
  344. package/src/admin/apps/zone-delegations.test.ts +218 -58
  345. package/src/admin/apps/zone-delegations.ts +146 -46
  346. package/src/admin/index.ts +1 -1
  347. package/src/admin/ui/src/components/ApiKeyForm.tsx +4 -3
  348. package/src/admin/ui/src/lib/config.ts +3 -3
  349. package/src/admin/ui/src/lib/query-client.ts +1 -1
  350. package/src/admin/ui/src/lib/review-queues.ts +10 -10
  351. package/src/admin/ui/src/lib/routes-setup.test.ts +1 -2
  352. package/src/admin/ui/src/lib/routes-setup.ts +3 -5
  353. package/src/admin/ui/src/lib/scopes.test.ts +3 -6
  354. package/src/admin/ui/src/lib/scopes.ts +2 -9
  355. package/src/admin/ui/src/lib/{zone-key-requests.ts → zone-delegation-requests.ts} +27 -31
  356. package/src/admin/ui/src/routeTree.gen.ts +17 -17
  357. package/src/admin/ui/src/routes/__root.tsx +1 -1
  358. package/src/admin/ui/src/routes/api-keys.tsx +2 -15
  359. package/src/admin/ui/src/routes/audit-logs.tsx +2 -2
  360. package/src/admin/ui/src/routes/index.tsx +11 -11
  361. package/src/admin/ui/src/routes/organizations.tsx +3 -1
  362. package/src/admin/ui/src/routes/organizations_.$orgId.tsx +3 -1
  363. package/src/admin/ui/src/routes/organizations_.$orgId_.setup.tsx +1 -3
  364. package/src/admin/ui/src/routes/{zone-key-requests.tsx → zone-delegation-requests.tsx} +228 -172
  365. package/src/admin/ui/vite.config.ts +14 -1
  366. package/src/admin/ui.generated.ts +412 -583
  367. package/src/analytics/tables/requestEvents.test.ts +13 -3
  368. package/src/analytics/tables/requestEvents.ts +4 -3
  369. package/src/apps/data/App.test.ts +72 -35
  370. package/src/apps/data/App.ts +29 -13
  371. package/src/apps/data/ZoneSelection.test.ts +53 -70
  372. package/src/apps/data/ZoneSelection.ts +2 -2
  373. package/src/apps/data/routes/activities.test.ts +140 -6
  374. package/src/apps/data/routes/activities.ts +124 -18
  375. package/src/apps/data/routes/balances.ts +5 -1
  376. package/src/apps/data/routes/blocks.ts +9 -13
  377. package/src/apps/data/routes/earn-transactions.local.test.ts +6 -1
  378. package/src/apps/data/routes/earn-transactions.test.ts +7 -3
  379. package/src/apps/data/routes/earn.bench.ts +0 -14
  380. package/src/apps/data/routes/earn.discovery.test.ts +4 -39
  381. package/src/apps/data/routes/earn.local.test.ts +27 -34
  382. package/src/apps/data/routes/earn.test.ts +172 -490
  383. package/src/apps/data/routes/earn.ts +103 -652
  384. package/src/apps/data/routes/exchanges.ts +20 -30
  385. package/src/apps/data/routes/fee-amm.ts +12 -18
  386. package/src/apps/data/routes/indexer.test.ts +14 -12
  387. package/src/apps/data/routes/receipts.ts +1 -1
  388. package/src/apps/data/routes/rpc.test.ts +23 -18
  389. package/src/apps/data/routes/rpc.ts +21 -21
  390. package/src/apps/data/routes/rpc.zone.test.ts +60 -6
  391. package/src/apps/data/routes/tokenlist.ts +4 -6
  392. package/src/apps/data/routes/tokens.test.ts +20 -0
  393. package/src/apps/data/routes/tokens.ts +10 -5
  394. package/src/apps/data/routes/transactions.ts +3 -3
  395. package/src/apps/data/routes/transfers.ts +4 -6
  396. package/src/apps/data/routes/valuation.test.ts +2 -4
  397. package/src/apps/data/routes/valuation.ts +4 -6
  398. package/src/apps/data/routes/webhooks.test.ts +1 -5
  399. package/src/apps/data/routes/webhooks.ts +30 -44
  400. package/src/apps/data/routes/zones.test.ts +35 -19
  401. package/src/apps/management/App.ts +3 -1
  402. package/src/apps/management/routes/api-keys.test.ts +112 -22
  403. package/src/apps/management/routes/api-keys.ts +30 -15
  404. package/src/apps/management/routes/billing.ts +17 -25
  405. package/src/apps/management/routes/cli.ts +12 -18
  406. package/src/apps/management/routes/fee-payer-usage.ts +5 -4
  407. package/src/apps/management/routes/invitations.ts +16 -21
  408. package/src/apps/management/routes/invite-links.ts +18 -26
  409. package/src/apps/management/routes/me.ts +9 -13
  410. package/src/apps/management/routes/members.ts +5 -7
  411. package/src/apps/management/routes/orgs.ts +8 -12
  412. package/src/apps/management/routes/projects.ts +8 -12
  413. package/src/apps/management/routes/route-usage.ts +5 -4
  414. package/src/apps/management/routes/routes-access-requests.test.ts +64 -2
  415. package/src/apps/management/routes/routes-access-requests.ts +99 -15
  416. package/src/apps/management/routes/scopes.test.ts +3 -3
  417. package/src/apps/management/routes/usage.ts +20 -28
  418. package/src/apps/management/routes/verified-token-requests.ts +9 -13
  419. package/src/apps/management/routes/zone-delegations.test.ts +3 -5
  420. package/src/apps/management/routes/zone-delegations.ts +4 -9
  421. package/src/apps/mpp/App.ts +3 -3
  422. package/src/apps/mpp/Mpp.zone.test.ts +49 -18
  423. package/src/apps/mpp/internal/Adapter.ts +1 -1
  424. package/src/apps/mpp/internal/Error.test.ts +3 -0
  425. package/src/apps/mpp/internal/Error.ts +1 -0
  426. package/src/apps/mpp/internal/Policy.test.ts +9 -8
  427. package/src/apps/mpp/internal/Policy.ts +37 -14
  428. package/src/apps/relay/App.sandbox.test.ts +15 -14
  429. package/src/apps/relay/App.test.ts +120 -61
  430. package/src/apps/relay/App.ts +28 -18
  431. package/src/apps/relay/App.zone-billing.test.ts +9 -4
  432. package/src/apps/routes/App.test.ts +21 -22
  433. package/src/db/Db.ts +34 -3
  434. package/src/db/Schema.ts +26 -26
  435. package/src/db/tables/rewardRuns.ts +39 -0
  436. package/src/db/tables/routesAccessRequests.test.ts +65 -0
  437. package/src/db/tables/routesAccessRequests.ts +25 -0
  438. package/src/db/tables/{zoneKeyRequests.test.ts → zoneDelegationRequests.test.ts} +36 -77
  439. package/src/db/tables/{zoneKeyRequests.ts → zoneDelegationRequests.ts} +69 -52
  440. package/src/db/tables/zoneDelegations.test.ts +5 -6
  441. package/src/db/tables/zoneDelegations.ts +25 -8
  442. package/src/handlers/internal/{multisig.store.test.ts → accounts.store.test.ts} +16 -16
  443. package/src/handlers/internal/{multisig.test.ts → accounts.test.ts} +85 -82
  444. package/src/handlers/internal/{multisig.ts → accounts.ts} +9 -9
  445. package/src/handlers/internal/sponsorship.ts +20 -20
  446. package/src/handlers/relay.test.ts +48 -46
  447. package/src/handlers/relay.ts +26 -26
  448. package/src/internal/Auth.test.ts +6 -2
  449. package/src/internal/Auth.ts +4 -10
  450. package/src/internal/EarnRates.ts +4 -6
  451. package/src/internal/EarnVaults.ts +2 -2
  452. package/src/internal/Log.test-d.ts +1 -0
  453. package/src/internal/Log.test.ts +4 -6
  454. package/src/internal/Log.ts +2 -6
  455. package/src/internal/Response.ts +0 -3
  456. package/src/internal/Schema.test.ts +42 -0
  457. package/src/internal/Schema.ts +36 -1
  458. package/src/internal/ZoneAccess.test.ts +515 -312
  459. package/src/internal/ZoneAccess.ts +410 -158
  460. package/src/internal/ZoneDelegation.test.ts +1 -41
  461. package/src/internal/ZoneDelegation.ts +10 -48
  462. package/src/internal/ZoneDelegationApproval.test.ts +22 -0
  463. package/src/internal/{ZoneKeyApproval.ts → ZoneDelegationApproval.ts} +4 -11
  464. package/src/internal/ZoneKeyRewrite.ts +231 -0
  465. package/src/internal/rewards/Campaigns.ts +2 -0
  466. package/src/internal/rewards/Distributions.ts +8 -12
  467. package/src/internal/rewards/Monitor.test.ts +64 -0
  468. package/src/internal/rewards/Monitor.ts +27 -4
  469. package/src/internal/rewards/Runner.ts +9 -1
  470. package/src/internal/routes/Action.ts +5 -6
  471. package/src/internal/routes/Deposit.ts +8 -12
  472. package/src/internal/routes/DepositAddress.ts +13 -19
  473. package/src/internal/routes/Policy.ts +2 -1
  474. package/src/internal/routes/Provider.ts +8 -8
  475. package/src/internal/routes/Reconciliation.ts +3 -2
  476. package/src/internal/routes/Transfer.ts +16 -24
  477. package/src/internal/routes/UniversalDepositAddress.ts +9 -12
  478. package/src/internal/routes/providers/SquidOrder.ts +3 -2
  479. package/src/internal/routes/providers/relay.ts +4 -3
  480. package/dist/admin/apps/zone-key-requests.d.ts +0 -406
  481. package/dist/admin/apps/zone-key-requests.d.ts.map +0 -1
  482. package/dist/admin/apps/zone-key-requests.js +0 -183
  483. package/dist/admin/apps/zone-key-requests.js.map +0 -1
  484. package/dist/db/tables/zoneKeyRequests.d.ts +0 -289
  485. package/dist/db/tables/zoneKeyRequests.d.ts.map +0 -1
  486. package/dist/db/tables/zoneKeyRequests.js.map +0 -1
  487. package/dist/handlers/internal/multisig.d.ts +0 -7
  488. package/dist/handlers/internal/multisig.d.ts.map +0 -1
  489. package/dist/internal/ZoneKeyApproval.d.ts.map +0 -1
  490. package/dist/internal/ZoneKeyApproval.js +0 -21
  491. package/dist/internal/ZoneKeyApproval.js.map +0 -1
  492. package/src/admin/apps/zone-key-requests.test.ts +0 -272
  493. package/src/admin/apps/zone-key-requests.ts +0 -231
  494. package/src/internal/ZoneKeyApproval.test.ts +0 -46
@@ -1,38 +1,124 @@
1
1
  import { Context } from 'hono'
2
2
 
3
3
  import * as TestApp from '../../test/App.js'
4
- import * as Organizations from '../db/tables/organizations.js'
5
- import * as Projects from '../db/tables/projects.js'
4
+ import * as Db from '../db/Db.js'
6
5
  import * as ZoneDelegations from '../db/tables/zoneDelegations.js'
7
6
  import * as Metrics from '../Metrics.js'
8
7
  import * as Auth from './Auth.js'
9
8
  import type * as Log from './Log.js'
9
+ import * as Store from './Store.js'
10
10
  import * as Viem from './Viem.js'
11
11
  import * as ZoneAccess from './ZoneAccess.js'
12
12
 
13
13
  const chainId = 421_700_001
14
+ const account = `0x${'ab'.repeat(20)}`
15
+ const token = `0x${'cd'.repeat(20)}`
16
+
17
+ /** A whole-zone delegation for the shared test organization. */
18
+ const delegation = {
19
+ accountsAll: true,
20
+ chainId,
21
+ environment: 'production',
22
+ orgId: TestApp.key.orgId,
23
+ tokensAll: true,
24
+ } as const
14
25
 
15
26
  describe('check', () => {
16
27
  test.each([
17
- { expected: 'wildcard', scopes: ['*'], verb: 'read' },
18
- { expected: 'scope', scopes: [`zone:${chainId}:read`], verb: 'read' },
19
- { expected: undefined, scopes: [`zone:${chainId}:write`], verb: 'read' },
20
- { expected: undefined, scopes: [`zone:${chainId + 1}:read`], verb: 'read' },
21
- { expected: 'scope', scopes: [`zone:${chainId}:write`], verb: 'write' },
22
- { expected: undefined, scopes: [`zone:${chainId}:read`], verb: 'write' },
23
- // Generic scopes admit nothing until delegations decide admission.
24
- { expected: undefined, scopes: ['zone:read'], verb: 'read' },
25
- ] as const)('grants $expected to $scopes for $verb', ({ expected, scopes, verb }) => {
26
- const c = context({ scopes })
27
- const result = ZoneAccess.check(c, { chainId, verb })
28
- expect(result.allowed ? result.grant : undefined).toBe(expected)
28
+ { delegated: [chainId], expected: 'delegation', scopes: ['zone:read'], verb: 'read', zoneTags: [chainId] }, // prettier-ignore
29
+ // The wildcard grants no Zone; a tagged `*` key reads only through an explicit `zone:read`.
30
+ { delegated: [chainId], expected: undefined, scopes: ['*'], verb: 'read', zoneTags: [chainId] }, // prettier-ignore
31
+ { delegated: [chainId], expected: 'delegation', scopes: ['*', 'zone:read'], verb: 'read', zoneTags: [chainId] }, // prettier-ignore
32
+ // Absent tags mean no Zone, whatever the scopes grant.
33
+ { delegated: [chainId], expected: undefined, scopes: ['zone:read'], verb: 'read', zoneTags: undefined }, // prettier-ignore
34
+ { delegated: [chainId], expected: undefined, scopes: ['zone:read'], verb: 'read', zoneTags: [chainId + 1] }, // prettier-ignore
35
+ // Tags select among the organization's delegations; they grant nothing alone.
36
+ {
37
+ delegated: [],
38
+ expected: undefined,
39
+ scopes: ['zone:read'],
40
+ verb: 'read',
41
+ zoneTags: [chainId],
42
+ },
43
+ // Delegations never loaded (no `load` ran) fail closed.
44
+ { delegated: undefined, expected: undefined, scopes: ['zone:read'], verb: 'read', zoneTags: [chainId] }, // prettier-ignore
45
+ // Retired per-Zone read scopes grant nothing, even beside tags and a delegation.
46
+ { delegated: [chainId], expected: undefined, scopes: [`zone:${chainId}:read`], verb: 'read', zoneTags: [chainId] }, // prettier-ignore
47
+ { delegated: [chainId], expected: undefined, scopes: ['data:read'], verb: 'read', zoneTags: [chainId] }, // prettier-ignore
48
+ // A stored per-Zone write scope keeps admitting its own Zone this release, without tags or a delegation.
49
+ { delegated: [], expected: 'scope', scopes: [`zone:${chainId}:write`], verb: 'write', zoneTags: undefined }, // prettier-ignore
50
+ { delegated: [chainId], expected: undefined, scopes: [`zone:${chainId + 1}:write`], verb: 'write', zoneTags: undefined }, // prettier-ignore
51
+ // The generic write scope broadcasts only to tagged Zones the organization is delegated; revoking the delegation stops it.
52
+ { delegated: [chainId], expected: 'scope', scopes: ['zone:write'], verb: 'write', zoneTags: [chainId] }, // prettier-ignore
53
+ { delegated: [], expected: undefined, scopes: ['zone:write'], verb: 'write', zoneTags: [chainId] }, // prettier-ignore
54
+ { delegated: undefined, expected: undefined, scopes: ['zone:write'], verb: 'write', zoneTags: [chainId] }, // prettier-ignore
55
+ { delegated: [chainId], expected: undefined, scopes: ['zone:write'], verb: 'write', zoneTags: undefined }, // prettier-ignore
56
+ { delegated: [chainId], expected: undefined, scopes: ['zone:write'], verb: 'write', zoneTags: [chainId + 1] }, // prettier-ignore
57
+ // The wildcard grants no Zone broadcast, and write scopes never read.
58
+ { delegated: [chainId], expected: undefined, scopes: ['*'], verb: 'write', zoneTags: [chainId] }, // prettier-ignore
59
+ { delegated: [chainId], expected: undefined, scopes: ['zone:read'], verb: 'write', zoneTags: [chainId] }, // prettier-ignore
60
+ { delegated: [chainId], expected: undefined, scopes: ['zone:write'], verb: 'read', zoneTags: [chainId] }, // prettier-ignore
61
+ ] as const)(
62
+ 'grants $expected to $scopes tagged $zoneTags for $verb with delegations $delegated',
63
+ ({ delegated, expected, scopes, verb, zoneTags }) => {
64
+ const c = context({ delegated, scopes, zoneTags })
65
+ const result = ZoneAccess.check(c, { chainId, verb })
66
+ expect(result.allowed ? result.grant : undefined).toBe(expected)
67
+ },
68
+ )
69
+
70
+ test('refuses delegated access as unavailable when the delegation read failed', () => {
71
+ const refusal = (options: {
72
+ scopes: readonly string[]
73
+ verb: ZoneAccess.Verb
74
+ zoneTags?: readonly number[]
75
+ }) => {
76
+ // prettier-ignore
77
+ const c = context(options)
78
+ c.set('zoneDelegations' as never, { failed: true } as never)
79
+ const result = ZoneAccess.check(c, { chainId, verb: options.verb })
80
+ return result.allowed ? result.grant : result.error.status
81
+ }
82
+ expect([
83
+ refusal({ scopes: ['zone:read'], verb: 'read', zoneTags: [chainId] }),
84
+ refusal({ scopes: ['zone:write'], verb: 'write', zoneTags: [chainId] }),
85
+ // Keys no delegation could admit keep their 403.
86
+ refusal({ scopes: ['zone:read'], verb: 'read', zoneTags: [chainId + 1] }),
87
+ refusal({ scopes: ['*'], verb: 'read', zoneTags: [chainId] }),
88
+ // Stored per-Zone write scopes never depend on delegations.
89
+ refusal({ scopes: [`zone:${chainId}:write`], verb: 'write' }),
90
+ ]).toEqual([503, 503, 403, 403, 'scope'])
91
+ })
92
+
93
+ test('keeps unreadable delegations eligible and fails listings closed with a 503', () => {
94
+ const c = context({ scopes: ['zone:read'], zoneTags: [chainId] })
95
+ c.set('zoneDelegations' as never, { failed: true } as never)
96
+ // Selection keeps the Zone so `authorize` answers the retriable 503 instead of a 403.
97
+ expect(ZoneAccess.eligible(c, { chainId })).toBe(true)
98
+ expect(() => ZoneAccess.visible(c, { chainId })).toThrow(
99
+ expect.objectContaining({ status: 503 }),
100
+ )
101
+ const untagged = context({ scopes: ['zone:read'], zoneTags: [chainId + 1] })
102
+ untagged.set('zoneDelegations' as never, { failed: true } as never)
103
+ expect([ZoneAccess.eligible(untagged, { chainId }), ZoneAccess.visible(untagged, { chainId })]).toEqual([false, false]) // prettier-ignore
104
+ })
105
+
106
+ test('needs a delegation covering every account and token without the request addresses', () => {
107
+ const c = context({ delegated: [], scopes: ['zone:read'], zoneTags: [chainId] })
108
+ c.set('zoneDelegations' as never, { records: [record({ accountsAll: false })] } as never)
109
+ expect(ZoneAccess.check(c, { chainId, verb: 'read' }).allowed).toBe(false)
110
+ c.set('zoneDelegations' as never, { records: [record({ tokensAll: false })] } as never)
111
+ expect(ZoneAccess.check(c, { chainId, verb: 'read' }).allowed).toBe(false)
112
+ c.set('zoneDelegations' as never, { records: [record({})] } as never)
113
+ expect(ZoneAccess.check(c, { chainId, verb: 'read' }).allowed).toBe(true)
29
114
  })
30
115
 
31
116
  test('never records a decision', () => {
32
- const c = context({ scopes: ['data:read'] })
117
+ const c = context({ delegated: [chainId], scopes: ['zone:read'], zoneTags: [chainId] })
33
118
  ZoneAccess.check(c, { chainId, verb: 'read' })
34
- ZoneAccess.check(context({ scopes: ['*'] }), { chainId, verb: 'read' })
119
+ ZoneAccess.check(context({ scopes: ['*'] }), { chainId, verb: 'write' })
35
120
  expect(c.get('zoneAccess' as never)).toBeUndefined()
121
+ expect(c.get('zoneAccessDecisions' as never)).toBeUndefined()
36
122
  })
37
123
 
38
124
  test('admits a caller Zone token and records it as the grant', () => {
@@ -68,7 +154,7 @@ describe('check', () => {
68
154
  "allowed": false,
69
155
  "error": {
70
156
  "code": "api_key_forbidden",
71
- "message": "Chain ID 421700001 is a Zone. Use an API key granting \`zone:421700001:read\`.",
157
+ "message": "Chain ID 421700001 is a Zone. Use an API key granting \`zone:read\` and tagged for Zone 421700001, issued to an organization delegated that Zone.",
72
158
  "status": 403,
73
159
  },
74
160
  }
@@ -91,38 +177,131 @@ describe('check', () => {
91
177
  denial({ suggestZoneToken: true, verb: 'write' }),
92
178
  ]).toMatchInlineSnapshot(`
93
179
  [
94
- "Chain ID 421700001 is a Zone. Use an API key granting \`zone:421700001:read\`.",
95
- "Chain id 421700001 is a zone. Use an API key granting \`zone:421700001:read\`, or pass a Zone token via \`X-Authorization-Token\`.",
96
- "Broadcasting to zone 421700001 requires \`zone:421700001:write\`.",
97
- "Broadcasting to zone 421700001 requires \`zone:421700001:write\`, or a Zone token via \`X-Authorization-Token\`.",
180
+ "Chain ID 421700001 is a Zone. Use an API key granting \`zone:read\` and tagged for Zone 421700001, issued to an organization delegated that Zone.",
181
+ "Chain ID 421700001 is a Zone. Use an API key granting \`zone:read\` and tagged for Zone 421700001, issued to an organization delegated that Zone, or pass a Zone token via \`X-Authorization-Token\`.",
182
+ "Broadcasting to zone 421700001 requires \`zone:write\` with a tag for Zone 421700001, issued to an organization delegated that Zone.",
183
+ "Broadcasting to zone 421700001 requires \`zone:write\` with a tag for Zone 421700001, issued to an organization delegated that Zone, or a Zone token via \`X-Authorization-Token\`.",
98
184
  ]
99
185
  `)
100
186
  })
101
187
  })
102
188
 
103
189
  describe('deny', () => {
104
- test('removes only the grant from the recorded decision', () => {
105
- const c = context({ scopes: [`zone:${chainId}:read`] })
190
+ test('removes only the grant from the recorded decision and its metered copy', async () => {
191
+ const c = context({ delegated: [chainId], scopes: ['zone:read'], zoneTags: [chainId] })
106
192
  ZoneAccess.deny(c)
107
193
  expect(c.get('zoneAccess' as never)).toBeUndefined()
108
- ZoneAccess.authorize(c, { chainId, verb: 'read' })
194
+ await ZoneAccess.authorize(c, { chainId, verb: 'read' })
109
195
  ZoneAccess.deny(c)
110
196
  expect(c.get('zoneAccess' as never)).toMatchInlineSnapshot(`
111
197
  {
112
198
  "accounts": [],
113
199
  "chainId": 421700001,
114
- "scopeGrant": "scope",
200
+ "delegation": {
201
+ "decision": "allow",
202
+ "id": "zd_421700001",
203
+ },
115
204
  "tokens": [],
116
205
  "verb": "read",
117
206
  }
118
207
  `)
208
+ expect(c.get('zoneAccessDecisions' as never)).toEqual([c.get('zoneAccess' as never)])
209
+ })
210
+ })
211
+
212
+ describe('ensure', () => {
213
+ test('loads delegations once for Zone-capable keys and fails closed on read errors', async () => {
214
+ const db = TestApp.database()
215
+ await TestApp.delegateZone(db, { chainIds: [chainId] })
216
+ const counts: string[] = []
217
+ const metrics = Metrics.from({
218
+ count: (name) => void counts.push(name),
219
+ flush() {},
220
+ gauge() {},
221
+ histogram() {},
222
+ })
223
+ const wire = (c: Context, source: Db.Source) => {
224
+ c.set('db' as never, source as never)
225
+ c.set('metrics' as never, metrics as never)
226
+ c.set('store' as never, Store.memory() as never)
227
+ c.set('zones' as never, new Map([[chainId, {}]]) as never)
228
+ return c
229
+ }
230
+ const reader = wire(context({ scopes: ['zone:read'], zoneTags: [chainId] }), db)
231
+ const untagged = wire(context({ scopes: ['zone:read'] }), db)
232
+ const writer = wire(context({ scopes: [`zone:${chainId}:write`] }), db)
233
+ const broken = wire(
234
+ context({ scopes: ['zone:read'], zoneTags: [chainId] }),
235
+ Db.postgres({ connectionString: 'postgresql://unused@127.0.0.1:1/unused' }),
236
+ )
237
+ await Promise.all([reader, untagged, writer, broken].map((c) => ZoneAccess.ensure(c)))
238
+
239
+ expect(
240
+ (reader.get('zoneDelegations' as never) as ZoneAccess.Delegations & { records: unknown[] })
241
+ .records,
242
+ ).toMatchObject([{ chainId }])
243
+ expect(untagged.get('zoneDelegations' as never)).toBeUndefined()
244
+ expect(writer.get('zoneDelegations' as never)).toBeUndefined()
245
+ expect(broken.get('zoneDelegations' as never)).toEqual({ failed: true })
246
+ expect(counts).toEqual([ZoneAccess.lookupFailureMetric])
247
+ expect(ZoneAccess.check(broken, { chainId, verb: 'read' }).allowed).toBe(false)
248
+ })
249
+
250
+ test('serves cached delegations while refreshing, and reads Postgres past the maximum age', async () => {
251
+ vi.useFakeTimers({ now: new Date('2026-10-23T20:40:00.000Z'), toFake: ['Date'] })
252
+ try {
253
+ const healthy = TestApp.database()
254
+ await TestApp.delegateZone(healthy, { chainIds: [chainId] })
255
+ const unreachable = Db.postgres({
256
+ connectionString: 'postgresql://unused@127.0.0.1:1/unused',
257
+ })
258
+ let down = false
259
+ const source: Db.Source = () => (down ? unreachable : healthy)
260
+ // One colo cache shared by every request, as `caches.default` is.
261
+ const store = Store.memory()
262
+ const tags: (Metrics.Tags | undefined)[] = []
263
+ const metrics = Metrics.from({
264
+ count: (_name, _value, tag) => void tags.push(tag),
265
+ flush() {},
266
+ gauge() {},
267
+ histogram() {},
268
+ })
269
+ const read = async () => {
270
+ const c = context({ scopes: ['zone:read'], zoneTags: [chainId] })
271
+ c.set('db' as never, source as never)
272
+ c.set('metrics' as never, metrics as never)
273
+ c.set('store' as never, store as never)
274
+ c.set('zones' as never, new Map([[chainId, {}]]) as never)
275
+ await ZoneAccess.ensure(c)
276
+ const result = ZoneAccess.check(c, { chainId, verb: 'read' })
277
+ return result.allowed ? result.grant : result.error.status
278
+ }
279
+ const elapse = (ms: number) => vi.setSystemTime(Date.now() + ms)
280
+
281
+ expect(await read()).toBe('delegation')
282
+ down = true
283
+ // A fresh entry answers without touching Postgres.
284
+ elapse(ZoneAccess.delegationTtl - 1_000)
285
+ expect(await read()).toBe('delegation')
286
+ expect(tags).toEqual([])
287
+ // An aging entry still answers; only its background refresh fails.
288
+ elapse(10_000)
289
+ expect(await read()).toBe('delegation')
290
+ await vi.waitFor(() => expect(tags).toEqual([{ phase: 'refresh' }]))
291
+ // Past the maximum age the request reads Postgres and refuses as unavailable.
292
+ elapse(ZoneAccess.delegationMaxAge)
293
+ expect(await read()).toBe(503)
294
+ expect(tags).toEqual([{ phase: 'refresh' }, { phase: 'request' }])
295
+ } finally {
296
+ vi.useRealTimers()
297
+ }
119
298
  })
120
299
  })
121
300
 
122
301
  describe('authorize', () => {
123
- test('records lowercase accounts and tokens, even when denied', () => {
302
+ test('records lowercase accounts and tokens, even when denied', async () => {
124
303
  const c = context({ scopes: ['data:read'] })
125
- ZoneAccess.authorize(c, {
304
+ await ZoneAccess.authorize(c, {
126
305
  accounts: [`0x${'AB'.repeat(20)}`],
127
306
  chainId,
128
307
  tokens: [`0x${'CD'.repeat(20)}`],
@@ -143,13 +322,9 @@ describe('authorize', () => {
143
322
  })
144
323
 
145
324
  test('records the accounts a Zone RPC batch reads', async () => {
146
- const entries: Log.Entry[] = []
147
- const reader = { ...TestApp.key, scopes: ['data:read', `zone:${chainId}:read`] }
148
- const app = TestApp.create({
149
- auth: { keys: [reader] },
150
- logger: (entry) => void entries.push(entry),
151
- zones: [TestApp.zone({ chainId, rpcUrl: `https://${chainId}.rpc.test` })],
152
- })
325
+ const reader = TestApp.zoneReader({ ...TestApp.key, scopes: ['data:read'] }, [chainId])
326
+ const { app, db, entries } = await setup([reader])
327
+ await ZoneDelegations.create(db, delegation)
153
328
 
154
329
  await app.request(`/rpc/${chainId}`, {
155
330
  body: JSON.stringify([
@@ -169,25 +344,19 @@ describe('authorize', () => {
169
344
  "0xabababababababababababababababababababab",
170
345
  "0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd",
171
346
  ],
172
- "zoneGrant": "scope",
347
+ "zoneGrant": "delegation",
173
348
  },
174
349
  ]
175
350
  `)
176
351
  })
177
352
 
178
353
  test('clears the grant when a later check refuses the request', async () => {
179
- const entries: Log.Entry[] = []
180
- const reader = { ...TestApp.key, scopes: ['data:read', `zone:${chainId}:read`] }
181
- const sandbox = {
182
- environment: 'sandbox' as const,
183
- id: 'key_sandbox',
184
- orgId: TestApp.key.orgId,
185
- scopes: ['data:read', `zone:${chainId + 1}:read`],
186
- token: 'secret_sandbox',
187
- }
188
- const app = TestApp.create({
189
- auth: { keys: [reader, sandbox] },
190
- logger: (entry) => void entries.push(entry),
354
+ const reader = TestApp.zoneReader({ ...TestApp.key, scopes: ['data:read'] }, [chainId])
355
+ const sandbox = TestApp.zoneReader(
356
+ { environment: 'sandbox' as const, id: 'key_sandbox', orgId: TestApp.key.orgId, scopes: ['data:read'], token: 'secret_sandbox' }, // prettier-ignore
357
+ [chainId + 1],
358
+ )
359
+ const { app, db, entries } = await setup([reader, sandbox], {
191
360
  zones: [
192
361
  TestApp.zone({ chainId, rpcUrl: `https://${chainId}.rpc.test` }),
193
362
  TestApp.zone({
@@ -197,23 +366,26 @@ describe('authorize', () => {
197
366
  }),
198
367
  ],
199
368
  })
369
+ await ZoneDelegations.create(db, delegation)
370
+ await ZoneDelegations.create(db, { ...delegation, chainId: chainId + 1, environment: 'sandbox' }) // prettier-ignore
200
371
 
201
- // A read scope admits the chain, but the method allowlist refuses the broadcast.
372
+ // The delegation admits the chain, but the method allowlist refuses the broadcast.
202
373
  const broadcast = await app.request(`/rpc/${chainId}`, {
203
374
  body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_sendRawTransaction' }),
204
375
  headers: { 'tempo-api-key': reader.token },
205
376
  method: 'POST',
206
377
  })
207
- // The sandbox refusal of a mainnet Zone follows the recorded scope grant.
378
+ // The sandbox refusal of a mainnet Zone follows the recorded grant.
208
379
  const mainnet = await app.request(`/v1/tokenlist?chainId=${chainId + 1}`, {
209
380
  headers: { 'tempo-api-key': sandbox.token },
210
381
  })
211
382
 
212
383
  expect([broadcast.status, mainnet.status]).toStrictEqual([403, 403])
213
384
  expect(
214
- entries.map(({ errorCode, zoneChainId, zoneGrant }) => ({
385
+ entries.map(({ errorCode, zoneChainId, zoneDelegationDecision, zoneGrant }) => ({
215
386
  errorCode,
216
387
  zoneChainId,
388
+ zoneDelegationDecision,
217
389
  zoneGrant,
218
390
  })),
219
391
  ).toMatchInlineSnapshot(`
@@ -221,11 +393,13 @@ describe('authorize', () => {
221
393
  {
222
394
  "errorCode": "api_key_forbidden",
223
395
  "zoneChainId": 421700001,
396
+ "zoneDelegationDecision": undefined,
224
397
  "zoneGrant": undefined,
225
398
  },
226
399
  {
227
400
  "errorCode": "api_key_forbidden",
228
401
  "zoneChainId": 421700002,
402
+ "zoneDelegationDecision": "allow",
229
403
  "zoneGrant": undefined,
230
404
  },
231
405
  ]
@@ -233,19 +407,15 @@ describe('authorize', () => {
233
407
  })
234
408
 
235
409
  test('logs Zone reads with the grant, accounts, and trusted client IP', async () => {
236
- const entries: Log.Entry[] = []
237
- const reader = { ...TestApp.key, scopes: ['data:read', `zone:${chainId}:read`] }
410
+ const reader = TestApp.zoneReader({ ...TestApp.key, scopes: ['data:read'] }, [chainId])
238
411
  const outsider = {
239
412
  id: 'key_outsider',
240
413
  orgId: TestApp.key.orgId,
241
414
  scopes: ['data:read'],
242
415
  token: 'secret_outsider',
243
416
  }
244
- const app = TestApp.create({
245
- auth: { keys: [outsider, reader] },
246
- logger: (entry) => void entries.push(entry),
247
- zones: [TestApp.zone({ chainId, rpcUrl: `https://${chainId}.rpc.test` })],
248
- })
417
+ const { app, db, entries } = await setup([outsider, reader])
418
+ await ZoneDelegations.create(db, delegation)
249
419
  const granted = new Request(`http://tempo-api.test/v1/tokenlist?chainId=${chainId}`, {
250
420
  headers: { 'tempo-api-key': reader.token },
251
421
  })
@@ -297,7 +467,7 @@ describe('authorize', () => {
297
467
  "status": 200,
298
468
  "zoneAccounts": [],
299
469
  "zoneChainId": 421700001,
300
- "zoneGrant": "scope",
470
+ "zoneGrant": "delegation",
301
471
  "zoneTokens": [],
302
472
  },
303
473
  {
@@ -348,52 +518,119 @@ describe('authorize', () => {
348
518
  `)
349
519
  })
350
520
 
351
- test('refuses a key holding only the generic zone:read scope', async () => {
352
- const entries: Log.Entry[] = []
353
- const generic = { ...TestApp.key, scopes: ['data:read', 'zone:read'] }
354
- const app = TestApp.create({
355
- auth: { keys: [generic] },
356
- logger: (entry) => void entries.push(entry),
357
- zones: [TestApp.zone({ chainId, rpcUrl: `https://${chainId}.rpc.test` })],
358
- })
359
- const read = await app.fetch(
360
- new Request(`http://tempo-api.test/v1/tokenlist?chainId=${chainId}`, {
361
- headers: { 'tempo-api-key': generic.token },
362
- }),
521
+ test('refuses zone:read keys without a tag or without a delegation, and retired per-Zone scopes', async () => {
522
+ const untagged = { ...TestApp.key, scopes: ['data:read', 'zone:read'] }
523
+ const undelegated = TestApp.zoneReader(
524
+ { ...TestApp.key, id: 'key_undelegated', orgId: 'org_undelegated', scopes: ['data:read'], token: 'secret_undelegated' }, // prettier-ignore
525
+ [chainId],
363
526
  )
527
+ const legacy = { ...TestApp.key, id: 'key_legacy', scopes: ['data:read', `zone:${chainId}:read`], token: 'secret_legacy' } // prettier-ignore
528
+ // The additive rewrite left both formats on the record; only the new one grants.
529
+ const both = { ...TestApp.key, id: 'key_both', scopes: ['data:read', `zone:${chainId}:read`, 'zone:read'], token: 'secret_both', zoneTags: [chainId] } // prettier-ignore
530
+ const { app, db, entries } = await setup([untagged, undelegated, legacy, both])
531
+ await ZoneDelegations.create(db, delegation)
532
+ const read = (key: { token: string }) =>
533
+ app.fetch(
534
+ new Request(`http://tempo-api.test/v1/tokenlist?chainId=${chainId}`, {
535
+ headers: { 'tempo-api-key': key.token },
536
+ }),
537
+ )
364
538
  const rpc = await app.fetch(
365
539
  new Request(`http://tempo-api.test/rpc/${chainId}`, {
366
540
  body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }),
367
- headers: { 'tempo-api-key': generic.token },
541
+ headers: { 'tempo-api-key': untagged.token },
368
542
  method: 'POST',
369
543
  }),
370
544
  )
371
- expect([read.status, rpc.status]).toEqual([403, 403])
372
- const { requestId: _requestId, ...error } = (await read.json()).error
545
+ const first = await read(untagged)
546
+ expect([first.status, (await read(undelegated)).status, (await read(legacy)).status, (await read(both)).status, rpc.status]).toEqual([403, 403, 403, 200, 403]) // prettier-ignore
547
+ const { requestId: _requestId, ...error } = (await first.json()).error
373
548
  expect(error).toMatchInlineSnapshot(`
374
549
  {
375
550
  "code": "api_key_forbidden",
376
- "message": "Chain ID 421700001 is a Zone. Use an API key granting \`zone:421700001:read\`.",
551
+ "message": "Chain ID 421700001 is a Zone. Use an API key granting \`zone:read\` and tagged for Zone 421700001, issued to an organization delegated that Zone.",
377
552
  }
378
553
  `)
379
- expect(entries.map(({ zoneGrant }) => zoneGrant)).toEqual([undefined, undefined])
554
+ // The raw RPC probe logs first; only the rewritten record earns a grant.
555
+ expect(entries.map(({ zoneGrant }) => zoneGrant)).toEqual([undefined, undefined, undefined, undefined, 'delegation']) // prettier-ignore
556
+ })
557
+
558
+ test('refuses reads with a retriable 503 when the delegation read fails', async () => {
559
+ const reader = TestApp.zoneReader({ ...TestApp.key, scopes: ['data:read'] }, [chainId])
560
+ const { app, counts, entries } = await setup([reader], {
561
+ db: Db.postgres({ connectionString: 'postgresql://unused@127.0.0.1:1/unused' }),
562
+ })
563
+ const response = await app.request(`/v1/tokenlist?chainId=${chainId}`, {
564
+ headers: { 'tempo-api-key': reader.token },
565
+ })
566
+ const { requestId: _requestId, ...error } = (await response.json()).error
567
+ expect({ error, status: response.status }).toMatchInlineSnapshot(`
568
+ {
569
+ "error": {
570
+ "code": "zone_access_unavailable",
571
+ "message": "Zone 421700001 access could not be verified. Retry the request.",
572
+ },
573
+ "status": 503,
574
+ }
575
+ `)
576
+ // A query stage on a failed read places the stall in Postgres (through Hyperdrive), not the cache.
577
+ expect(stages(response)).toEqual(expect.arrayContaining(['zone_delegations', 'zone_delegations_cache', 'zone_delegations_query'])) // prettier-ignore
578
+ expect(entries.map(({ errorCode, zoneGrant }) => ({ errorCode, zoneGrant }))).toEqual([{ errorCode: 'zone_access_unavailable', zoneGrant: undefined }]) // prettier-ignore
579
+ expect(counts.filter(({ name }) => name === ZoneAccess.lookupFailureMetric).map(({ tags }) => tags)).toEqual([{ phase: 'request' }]) // prettier-ignore
580
+ })
581
+
582
+ test('refuses raw Zone RPC with a retriable 503 when the delegation read fails', async () => {
583
+ const reader = TestApp.zoneReader({ ...TestApp.key, scopes: ['data:read'] }, [chainId])
584
+ const { app } = await setup([reader], {
585
+ db: Db.postgres({ connectionString: 'postgresql://unused@127.0.0.1:1/unused' }),
586
+ })
587
+ const response = await app.request(`/rpc/${chainId}`, {
588
+ body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }),
589
+ headers: { 'content-type': 'application/json', 'tempo-api-key': reader.token },
590
+ method: 'POST',
591
+ })
592
+ expect([response.status, (await response.json()).error.code]).toEqual([503, 'zone_access_unavailable']) // prettier-ignore
593
+ })
594
+
595
+ test('refuses Zone broadcasts and inferred Zones with a 503 when the delegation read fails', async () => {
596
+ const reader = TestApp.zoneReader({ ...TestApp.key, scopes: ['data:read'] }, [chainId])
597
+ const writer = { ...TestApp.key, id: 'key_writer', scopes: ['data:read', 'zone:write'], token: 'secret_writer', zoneTags: [chainId] } // prettier-ignore
598
+ const { app } = await setup([reader, writer], {
599
+ db: Db.postgres({ connectionString: 'postgresql://unused@127.0.0.1:1/unused' }),
600
+ })
601
+ const inferred = await app.request(`/v1/addresses/${account}/activities?include=zones`, {
602
+ headers: { 'tempo-api-key': reader.token },
603
+ })
604
+ const rpc = (method: string, params: readonly unknown[]) =>
605
+ app.request(`/rpc/${chainId}`, {
606
+ body: JSON.stringify({ id: 1, jsonrpc: '2.0', method, params }),
607
+ headers: { 'content-type': 'application/json', 'tempo-api-key': writer.token },
608
+ method: 'POST',
609
+ })
610
+ const broadcast = await rpc('eth_sendRawTransaction', [`0x${'02'.repeat(8)}`])
611
+ // A read the write-only key could never perform keeps its 403.
612
+ const read = await rpc('eth_chainId', [])
613
+ const codes = await Promise.all([inferred, broadcast, read].map(async (response) => [response.status, (await response.json()).error.code])) // prettier-ignore
614
+ expect(codes).toEqual([[503, 'zone_access_unavailable'], [503, 'zone_access_unavailable'], [403, 'api_key_forbidden']]) // prettier-ignore
615
+ })
616
+
617
+ test('times the delegation read in Server-Timing', async () => {
618
+ const reader = TestApp.zoneReader({ ...TestApp.key, scopes: ['data:read'] }, [chainId])
619
+ const { app, db } = await setup([reader])
620
+ await ZoneDelegations.create(db, delegation)
621
+ const response = await app.request(`/v1/tokenlist?chainId=${chainId}`, {
622
+ headers: { 'tempo-api-key': reader.token },
623
+ })
624
+ expect(response.status).toBe(200)
625
+ expect(stages(response)).toEqual(expect.arrayContaining(['zone_delegations', 'zone_delegations_cache', 'zone_delegations_query'])) // prettier-ignore
380
626
  })
381
627
 
382
628
  test('records a granted Zone for inferred-Zone requests, never a denied sibling', async () => {
383
- const entries: Log.Entry[] = []
384
629
  const other = chainId + 1
385
- const reader = { ...TestApp.key, scopes: ['data:read', `zone:${chainId}:read`] }
386
- const app = TestApp.create({
387
- auth: { keys: [reader] },
388
- logger: (entry) => void entries.push(entry),
389
- zones: [other, chainId].map((id) =>
390
- TestApp.zone({
391
- chainId: id,
392
- rpcUrl: `https://${id}.rpc.test`,
393
- tidxUrl: `https://${id}.tidx.test`,
394
- }),
395
- ),
396
- })
630
+ const reader = TestApp.zoneReader({ ...TestApp.key, scopes: ['data:read'] }, [chainId])
631
+ const { app, db, entries } = await setup([reader], { zones: zonesFor([other, chainId]) })
632
+ await ZoneDelegations.create(db, delegation)
633
+ await ZoneDelegations.create(db, { ...delegation, chainId: other })
397
634
 
398
635
  await app.request(`/v1/addresses/0x${'AB'.repeat(20)}/activities?include=zones`, {
399
636
  headers: { 'tempo-api-key': reader.token },
@@ -412,153 +649,37 @@ describe('authorize', () => {
412
649
  "0xabababababababababababababababababababab",
413
650
  ],
414
651
  "zoneChainId": 421700001,
415
- "zoneGrant": "scope",
652
+ "zoneGrant": "delegation",
416
653
  },
417
654
  ]
418
655
  `)
419
656
  })
420
- })
421
657
 
422
- describe('settle', () => {
423
- const account = `0x${'ab'.repeat(20)}`
424
- const token = `0x${'cd'.repeat(20)}`
425
- const delegation = {
426
- accountsAll: true,
427
- chainId,
428
- environment: 'production',
429
- orgId: TestApp.key.orgId,
430
- projects: 'all',
431
- tokensAll: true,
432
- } as const
658
+ test('decides each inferred Zone on its own against the request addresses', async () => {
659
+ const other = chainId + 1
660
+ const reader = TestApp.zoneReader({ ...TestApp.key, scopes: ['data:read'] }, [chainId, other])
661
+ const { app, db, entries, tags } = await setup([reader], { zones: zonesFor([other, chainId]) })
662
+ await ZoneDelegations.create(db, delegation)
663
+ // The sibling Zone lists another account only, so it drops out of the selection.
664
+ await ZoneDelegations.create(db, { ...delegation, accounts: [`0x${'ef'.repeat(20)}`], accountsAll: false, chainId: other }) // prettier-ignore
433
665
 
434
- /** Builds an app whose Zone requests are logged and metered, over a database holding the key's organization. */
435
- async function setup(keys: readonly TestApp.kvStore.Key[]) {
436
- const db = TestApp.database()
437
- await Organizations.create(db, { id: TestApp.key.orgId, name: 'Delegated' })
438
- const entries: Log.Entry[] = []
439
- const counts: { name: string; tags: Metrics.Tags | undefined }[] = []
440
- const app = TestApp.create({
441
- auth: { keys },
442
- db,
443
- logger: (entry) => void entries.push(entry),
444
- metrics: Metrics.from({
445
- count: (name, _value, tags) => void counts.push({ name, tags }),
446
- flush() {},
447
- gauge() {},
448
- histogram() {},
449
- }),
450
- zones: [TestApp.zone({ chainId, rpcUrl: `https://${chainId}.rpc.test` })],
666
+ const response = await app.request(`/v1/addresses/${account}/activities?include=zones`, {
667
+ headers: { 'tempo-api-key': reader.token },
451
668
  })
452
- const request = (path: string, key: TestApp.kvStore.Key | undefined) =>
453
- app.request(path, key === undefined ? {} : { headers: { 'tempo-api-key': key.token } })
454
- const decisions = () =>
455
- entries.map(
456
- ({
457
- status,
458
- zoneDecisionAgreement,
459
- zoneDelegationDecision,
460
- zoneDelegationId,
461
- zoneGrant,
462
- }) => ({
463
- // prettier-ignore
464
- agreement: zoneDecisionAgreement,
465
- delegation: zoneDelegationDecision,
466
- delegationId: zoneDelegationId === undefined ? undefined : 'zd',
467
- grant: zoneGrant,
468
- status,
469
- }),
470
- )
471
- const tags = () =>
472
- counts.filter(({ name }) => name === ZoneAccess.decisionMetric).map(({ tags }) => tags)
473
- return { app, db, decisions, request, tags }
474
- }
475
-
476
- test('records agreement and disagreement without changing admission', async () => {
477
- const reader = { ...TestApp.key, scopes: ['data:read', `zone:${chainId}:read`] }
478
- const outsider = { ...TestApp.key, id: 'key_outsider', scopes: ['data:read'], token: 'secret_outsider' } // prettier-ignore
479
- const path = `/v1/tokenlist?chainId=${chainId}`
480
669
 
481
- // Without a delegation the scope grant disagrees and the outsider's denial agrees.
482
- const before = await setup([reader, outsider])
483
- expect((await before.request(path, reader)).status).toBe(200)
484
- expect((await before.request(path, outsider)).status).toBe(403)
485
- // A whole-zone delegation agrees with the grant and disagrees with the denial, which stands.
486
- // A fresh app, because delegations are cached per colo and client cache directives are ignored.
487
- const after = await setup([reader, outsider])
488
- await ZoneDelegations.create(after.db, delegation)
489
- expect((await after.request(path, reader)).status).toBe(200)
490
- expect((await after.request(path, outsider)).status).toBe(403)
491
- // Anonymous callers hold no organization, so no delegation decision is made.
492
- expect((await after.request(path, undefined)).status).toBe(403)
493
-
494
- expect([...before.decisions(), ...after.decisions()]).toMatchInlineSnapshot(`
495
- [
496
- {
497
- "agreement": false,
498
- "delegation": "deny",
499
- "delegationId": undefined,
500
- "grant": "scope",
501
- "status": 200,
502
- },
503
- {
504
- "agreement": true,
505
- "delegation": "deny",
506
- "delegationId": undefined,
507
- "grant": undefined,
508
- "status": 403,
509
- },
510
- {
511
- "agreement": true,
512
- "delegation": "allow",
513
- "delegationId": "zd",
514
- "grant": "scope",
515
- "status": 200,
516
- },
517
- {
518
- "agreement": true,
519
- "delegation": "deny",
520
- "delegationId": "zd",
521
- "grant": undefined,
522
- "status": 403,
523
- },
524
- {
525
- "agreement": undefined,
526
- "delegation": undefined,
527
- "delegationId": undefined,
528
- "grant": undefined,
529
- "status": 403,
530
- },
531
- ]
532
- `)
533
- expect([...before.tags(), ...after.tags()]).toMatchInlineSnapshot(`
670
+ expect(response.status).not.toBe(403)
671
+ expect(entries.map(({ zoneChainId, zoneGrant }) => ({ zoneChainId, zoneGrant }))).toEqual([
672
+ { zoneChainId: chainId, zoneGrant: 'delegation' },
673
+ ])
674
+ expect(tags()).toMatchInlineSnapshot(`
534
675
  [
535
676
  {
536
- "agreement": "disagree",
537
- "chain_id": 421700001,
538
- "grant": "scope",
539
- "outcome": "allow",
540
- },
541
- {
542
- "agreement": "agree",
543
- "chain_id": 421700001,
544
- "grant": "none",
545
- "outcome": "deny",
546
- },
547
- {
548
- "agreement": "agree",
549
677
  "chain_id": 421700001,
550
- "grant": "scope",
678
+ "grant": "delegation",
551
679
  "outcome": "allow",
552
680
  },
553
681
  {
554
- "agreement": "agree",
555
- "chain_id": 421700001,
556
- "grant": "none",
557
- "outcome": "deny",
558
- },
559
- {
560
- "agreement": "skipped",
561
- "chain_id": 421700001,
682
+ "chain_id": 421700002,
562
683
  "grant": "none",
563
684
  "outcome": "deny",
564
685
  },
@@ -567,68 +688,62 @@ describe('settle', () => {
567
688
  })
568
689
 
569
690
  test('ignores client cache directives when reading delegations', async () => {
570
- const reader = { ...TestApp.key, scopes: ['data:read', `zone:${chainId}:read`] }
571
- const { app, db, decisions } = await setup([reader])
691
+ const reader = TestApp.zoneReader({ ...TestApp.key, scopes: ['data:read'] }, [chainId])
692
+ const { app, db } = await setup([reader])
572
693
  const path = `/v1/tokenlist?chainId=${chainId}`
573
694
  const headers = { 'cache-control': 'no-cache', 'tempo-api-key': reader.token }
574
- expect((await app.request(path, { headers })).status).toBe(200)
695
+ expect((await app.request(path, { headers })).status).toBe(403)
575
696
  await ZoneDelegations.create(db, delegation)
576
697
  // The cached empty read stands for its TTL even though the caller asked for a refresh.
577
- expect((await app.request(path, { headers })).status).toBe(200)
578
- expect(decisions().map(({ delegation }) => delegation)).toMatchInlineSnapshot(`
579
- [
580
- "deny",
581
- "deny",
582
- ]
583
- `)
698
+ expect((await app.request(path, { headers })).status).toBe(403)
584
699
  })
585
700
 
586
- test('narrows by project and account; zone-wide routes need every account', async () => {
587
- const scoped = { ...TestApp.key, projectId: 'prj_a', scopes: ['data:read', `zone:${chainId}:read`] } // prettier-ignore
701
+ test('narrows by account; zone-wide routes need every account', async () => {
702
+ const scoped = TestApp.zoneReader({ ...TestApp.key, projectId: 'prj_a', scopes: ['data:read'] }, [chainId]) // prettier-ignore
703
+ // A key of another project reads the same delegation: narrowing is by key tag, never by project.
588
704
  const sibling = { ...scoped, id: 'key_b', projectId: 'prj_b', token: 'secret_b' }
589
- const organizationWide = { ...TestApp.key, id: 'key_org', scopes: scoped.scopes, token: 'secret_org' } // prettier-ignore
590
- const { db, decisions, request } = await setup([scoped, sibling, organizationWide])
591
- await Projects.create(db, { id: 'prj_a', name: 'A', orgId: TestApp.key.orgId })
592
- await ZoneDelegations.create(db, { ...delegation, accounts: [account], accountsAll: false, projects: ['prj_a'] }) // prettier-ignore
705
+ const { db, decisions, request } = await setup([scoped, sibling])
706
+ await ZoneDelegations.create(db, { ...delegation, accounts: [account], accountsAll: false })
593
707
  const balances = (address: string) => `/v1/addresses/${address}/balances?chainId=${chainId}`
594
708
 
595
- // Project narrowing: the named project passes; a sibling project and an organization-wide key do not.
596
709
  await request(balances(account), scoped)
597
710
  await request(balances(account), sibling)
598
- await request(balances(account), organizationWide)
599
711
  // Account narrowing: another account fails, and a zone-wide list fails without `all`.
600
712
  await request(balances(`0x${'ef'.repeat(20)}`), scoped)
601
713
  await request(`/v1/tokenlist?chainId=${chainId}`, scoped)
602
714
 
603
- expect(decisions().map(({ delegation, status }) => ({ delegation, status })))
604
- .toMatchInlineSnapshot(`
715
+ expect(decisions()).toMatchInlineSnapshot(`
605
716
  [
606
717
  {
607
718
  "delegation": "allow",
719
+ "delegationId": "zd",
720
+ "grant": "delegation",
608
721
  "status": 502,
609
722
  },
610
723
  {
611
- "delegation": "deny",
612
- "status": 502,
613
- },
614
- {
615
- "delegation": "deny",
724
+ "delegation": "allow",
725
+ "delegationId": "zd",
726
+ "grant": "delegation",
616
727
  "status": 502,
617
728
  },
618
729
  {
619
730
  "delegation": "deny",
620
- "status": 502,
731
+ "delegationId": "zd",
732
+ "grant": undefined,
733
+ "status": 403,
621
734
  },
622
735
  {
623
736
  "delegation": "deny",
624
- "status": 200,
737
+ "delegationId": "zd",
738
+ "grant": undefined,
739
+ "status": 403,
625
740
  },
626
741
  ]
627
742
  `)
628
743
  })
629
744
 
630
745
  test('treats a raw RPC batch with any zone-wide read as zone-wide', async () => {
631
- const reader = { ...TestApp.key, scopes: ['data:read', `zone:${chainId}:read`] }
746
+ const reader = TestApp.zoneReader({ ...TestApp.key, scopes: ['data:read'] }, [chainId])
632
747
  const { app, db, decisions } = await setup([reader])
633
748
  await ZoneDelegations.create(db, { ...delegation, accounts: [account], accountsAll: false })
634
749
  const rpc = (requests: readonly { method: string; params?: unknown[] }[]) =>
@@ -641,17 +756,28 @@ describe('settle', () => {
641
756
  // Chain metadata exposes no account, so it keeps the batch account-scoped; logs do not.
642
757
  await rpc([{ method: 'eth_getBalance', params: [account, 'latest'] }, { method: 'eth_blockNumber', params: [] }]) // prettier-ignore
643
758
  await rpc([{ method: 'eth_getBalance', params: [account, 'latest'] }, { method: 'eth_getLogs', params: [{}] }]) // prettier-ignore
644
- expect(decisions().map(({ delegation }) => delegation)).toMatchInlineSnapshot(`
759
+ // Admitted batches reach body validation (no content type here); refused ones never do.
760
+ expect(decisions().map(({ delegation, status }) => [delegation, status]))
761
+ .toMatchInlineSnapshot(`
645
762
  [
646
- "allow",
647
- "allow",
648
- "deny",
763
+ [
764
+ "allow",
765
+ 415,
766
+ ],
767
+ [
768
+ "allow",
769
+ 415,
770
+ ],
771
+ [
772
+ "deny",
773
+ 403,
774
+ ],
649
775
  ]
650
776
  `)
651
777
  })
652
778
 
653
779
  test('narrows by token on token routes', async () => {
654
- const reader = { ...TestApp.key, scopes: ['data:read', `zone:${chainId}:read`] }
780
+ const reader = TestApp.zoneReader({ ...TestApp.key, scopes: ['data:read'] }, [chainId])
655
781
  const { db, decisions, request } = await setup([reader])
656
782
  await ZoneDelegations.create(db, { ...delegation, tokens: [token], tokensAll: false })
657
783
 
@@ -660,18 +786,31 @@ describe('settle', () => {
660
786
  // A list naming no token sees every token, so a token list does not cover it.
661
787
  await request(`/v1/tokenlist?chainId=${chainId}`, reader)
662
788
 
663
- expect(decisions().map(({ delegation }) => delegation)).toMatchInlineSnapshot(`
789
+ expect(decisions().map(({ delegation, status }) => [delegation, status]))
790
+ .toMatchInlineSnapshot(`
664
791
  [
665
- "allow",
666
- "deny",
667
- "deny",
792
+ [
793
+ "allow",
794
+ 404,
795
+ ],
796
+ [
797
+ "deny",
798
+ 403,
799
+ ],
800
+ [
801
+ "deny",
802
+ 403,
803
+ ],
668
804
  ]
669
805
  `)
670
806
  })
807
+ })
671
808
 
672
- test('compares against the scope decision, not later refusals, and skips writes', async () => {
673
- const reader = { ...TestApp.key, scopes: ['data:read', `zone:${chainId}:read`, `zone:${chainId}:write`] } // prettier-ignore
674
- const { app, db, decisions, tags } = await setup([reader])
809
+ describe('settle', () => {
810
+ test('meters every recorded decision with its final outcome', async () => {
811
+ const reader = TestApp.zoneReader({ ...TestApp.key, scopes: ['data:read', `zone:${chainId}:write`] }, [chainId]) // prettier-ignore
812
+ const outsider = { ...TestApp.key, id: 'key_outsider', scopes: ['data:read'], token: 'secret_outsider' } // prettier-ignore
813
+ const { app, db, request, tags } = await setup([reader, outsider])
675
814
  await ZoneDelegations.create(db, delegation)
676
815
  const rpc = (method: string) =>
677
816
  app.request(`/rpc/${chainId}`, {
@@ -679,49 +818,39 @@ describe('settle', () => {
679
818
  headers: { 'tempo-api-key': reader.token },
680
819
  method: 'POST',
681
820
  })
682
- // The read scope admits the chain, the method allowlist refuses the call afterwards; the
683
- // delegation agrees with the scope grant because the allowlist survives enforcement.
821
+ expect((await request(`/v1/tokenlist?chainId=${chainId}`, reader)).status).toBe(200)
822
+ expect((await request(`/v1/tokenlist?chainId=${chainId}`, outsider)).status).toBe(403)
823
+ // The delegation admits the chain, the method allowlist refuses the call afterwards.
684
824
  expect((await rpc('eth_sign')).status).toBe(403)
685
- // Broadcasts are user-signed and stay on key scopes, so they are never compared.
686
- const broadcast = await rpc('eth_sendRawTransaction')
687
- expect(broadcast.status).not.toBe(403)
688
- expect([decisions(), tags()]).toMatchInlineSnapshot(`
825
+ // Broadcasts are user-signed and stay on key scopes.
826
+ expect((await rpc('eth_sendRawTransaction')).status).not.toBe(403)
827
+ expect(tags()).toMatchInlineSnapshot(`
689
828
  [
690
- [
691
- {
692
- "agreement": true,
693
- "delegation": "allow",
694
- "delegationId": "zd",
695
- "grant": undefined,
696
- "status": 403,
697
- },
698
- {
699
- "agreement": undefined,
700
- "delegation": undefined,
701
- "delegationId": undefined,
702
- "grant": "scope",
703
- "status": 415,
704
- },
705
- ],
706
- [
707
- {
708
- "agreement": "agree",
709
- "chain_id": 421700001,
710
- "grant": "none",
711
- "outcome": "deny",
712
- },
713
- {
714
- "agreement": "skipped",
715
- "chain_id": 421700001,
716
- "grant": "scope",
717
- "outcome": "allow",
718
- },
719
- ],
829
+ {
830
+ "chain_id": 421700001,
831
+ "grant": "delegation",
832
+ "outcome": "allow",
833
+ },
834
+ {
835
+ "chain_id": 421700001,
836
+ "grant": "none",
837
+ "outcome": "deny",
838
+ },
839
+ {
840
+ "chain_id": 421700001,
841
+ "grant": "none",
842
+ "outcome": "deny",
843
+ },
844
+ {
845
+ "chain_id": 421700001,
846
+ "grant": "scope",
847
+ "outcome": "allow",
848
+ },
720
849
  ]
721
850
  `)
722
851
  })
723
852
 
724
- test('skips forwarded Zone tokens and contexts without a database', async () => {
853
+ test('meters bare contexts, including forwarded Zone tokens and unloaded delegations', async () => {
725
854
  const counts: Metrics.Tags[] = []
726
855
  const metrics = Metrics.from({
727
856
  count: (_name, _value, tags) => void counts.push(tags ?? {}),
@@ -731,15 +860,15 @@ describe('settle', () => {
731
860
  })
732
861
  const forwarded = context({ scopes: ['data:read'] })
733
862
  forwarded.set('metrics' as never, metrics as never)
734
- ZoneAccess.authorize(forwarded, { chainId, verb: 'read', zoneToken: true })
735
- await ZoneAccess.settle(forwarded)
736
- // No database or cache store is wired, so the comparison is unavailable rather than a decision.
737
- const bare = context({ scopes: [`zone:${chainId}:read`] })
863
+ await ZoneAccess.authorize(forwarded, { chainId, verb: 'read', zoneToken: true })
864
+ ZoneAccess.settle(forwarded)
865
+ // Nothing loaded the organization's delegations, so the read fails closed.
866
+ const bare = context({ scopes: ['zone:read'], zoneTags: [chainId] })
738
867
  bare.set('metrics' as never, metrics as never)
739
- ZoneAccess.authorize(bare, { chainId, verb: 'read' })
740
- await ZoneAccess.settle(bare)
868
+ await ZoneAccess.authorize(bare, { chainId, verb: 'read' })
869
+ ZoneAccess.settle(bare)
741
870
  const silent = context({ scopes: ['data:read'] })
742
- await ZoneAccess.settle(silent)
871
+ ZoneAccess.settle(silent)
743
872
 
744
873
  expect([
745
874
  (forwarded.get('zoneAccess' as never) as ZoneAccess.Access).delegation,
@@ -753,17 +882,14 @@ describe('settle', () => {
753
882
  undefined,
754
883
  [
755
884
  {
756
- "agreement": "skipped",
757
885
  "chain_id": 421700001,
758
886
  "grant": "zone_token",
759
887
  "outcome": "allow",
760
888
  },
761
- {},
762
889
  {
763
- "agreement": "unavailable",
764
890
  "chain_id": 421700001,
765
- "grant": "scope",
766
- "outcome": "allow",
891
+ "grant": "none",
892
+ "outcome": "deny",
767
893
  },
768
894
  ],
769
895
  ]
@@ -771,12 +897,75 @@ describe('settle', () => {
771
897
  })
772
898
  })
773
899
 
900
+ /** Builds an app whose Zone requests are logged and metered, over a database holding the key's organization. */
901
+ async function setup(keys: readonly TestApp.kvStore.Key[], options: setup.Options = {}) {
902
+ const db = options.db ?? TestApp.database()
903
+ if (options.db === undefined) await TestApp.delegateZone(db, { chainIds: [] })
904
+ const entries: Log.Entry[] = []
905
+ const counts: { name: string; tags: Metrics.Tags | undefined }[] = []
906
+ const app = TestApp.create({
907
+ auth: { keys },
908
+ db,
909
+ logger: (entry) => void entries.push(entry),
910
+ metrics: Metrics.from({
911
+ count: (name, _value, tags) => void counts.push({ name, tags }),
912
+ flush() {},
913
+ gauge() {},
914
+ histogram() {},
915
+ }),
916
+ zones: options.zones ?? zonesFor([chainId]),
917
+ })
918
+ const request = (path: string, key: TestApp.kvStore.Key | undefined) =>
919
+ app.request(path, key === undefined ? {} : { headers: { 'tempo-api-key': key.token } })
920
+ const decisions = () =>
921
+ entries.map(({ status, zoneDelegationDecision, zoneDelegationId, zoneGrant }) => ({
922
+ delegation: zoneDelegationDecision,
923
+ delegationId: zoneDelegationId === undefined ? undefined : 'zd',
924
+ grant: zoneGrant,
925
+ status,
926
+ }))
927
+ const tags = () =>
928
+ counts.filter(({ name }) => name === ZoneAccess.decisionMetric).map(({ tags }) => tags)
929
+ return { app, counts, db, decisions, entries, request, tags }
930
+ }
931
+
932
+ declare namespace setup {
933
+ type Options = {
934
+ db?: Db.Db | undefined
935
+ zones?: readonly ReturnType<typeof TestApp.zone>[] | undefined
936
+ }
937
+ }
938
+
939
+ function stages(response: globalThis.Response) {
940
+ return (response.headers.get('server-timing') ?? '')
941
+ .split(', ')
942
+ .map((metric) => metric.split(';')[0])
943
+ }
944
+
945
+ function zonesFor(chainIds: readonly number[]) {
946
+ return chainIds.map(
947
+ (id) =>
948
+ TestApp.zone({ chainId: id, rpcUrl: `https://${id}.rpc.test`, tidxUrl: `https://${id}.tidx.test` }), // prettier-ignore
949
+ )
950
+ }
951
+
952
+ /** A delegation row as `load` leaves it on the context. */
953
+ function record(options: Partial<ZoneDelegations.Record>): ZoneDelegations.Record {
954
+ return {
955
+ ...delegation,
956
+ createdAt: '2026-01-01T00:00:00.000Z',
957
+ id: `zd_${options.chainId ?? chainId}`,
958
+ updatedAt: '2026-01-01T00:00:00.000Z',
959
+ ...options,
960
+ }
961
+ }
962
+
774
963
  function request() {
775
964
  return new Request('http://tempo-api.test/')
776
965
  }
777
966
 
778
- /** Builds a context authenticated as an API key holding `scopes`. */
779
- function context(options: { scopes: readonly string[] }) {
967
+ /** Builds a context authenticated as an API key, with its organization's delegations preloaded when given. */
968
+ function context(options: context.Options) {
780
969
  const c = new Context(request())
781
970
  const principal = {
782
971
  apiKey: {
@@ -784,12 +973,26 @@ function context(options: { scopes: readonly string[] }) {
784
973
  id: 'key_test',
785
974
  orgId: 'org_test',
786
975
  scopes: options.scopes,
976
+ ...(options.zoneTags === undefined ? {} : { zoneTags: options.zoneTags }),
787
977
  },
788
978
  environment: 'production',
789
979
  id: 'key_test',
790
980
  orgId: 'org_test',
981
+ ...(options.projectId === undefined ? {} : { projectId: options.projectId }),
791
982
  type: 'api_key',
983
+ ...(options.zoneTags === undefined ? {} : { zoneTags: options.zoneTags }),
792
984
  }
793
985
  c.set('principal' as never, principal as never)
986
+ if (options.delegated)
987
+ c.set('zoneDelegations' as never, { records: options.delegated.map((id) => record({ chainId: id })) } as never) // prettier-ignore
794
988
  return c
795
989
  }
990
+
991
+ declare namespace context {
992
+ type Options = {
993
+ delegated?: readonly number[] | undefined
994
+ projectId?: string | undefined
995
+ scopes: readonly string[]
996
+ zoneTags?: readonly number[] | undefined
997
+ }
998
+ }