tapimo 0.4.0 → 0.4.3

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 (255) hide show
  1. package/README.md +1 -1
  2. package/dist/ApiKey.d.ts +1 -9
  3. package/dist/ApiKey.d.ts.map +1 -1
  4. package/dist/ApiKey.js +2 -3
  5. package/dist/ApiKey.js.map +1 -1
  6. package/dist/ApiKeys.d.ts +53 -27
  7. package/dist/ApiKeys.d.ts.map +1 -1
  8. package/dist/ApiKeys.js +32 -17
  9. package/dist/ApiKeys.js.map +1 -1
  10. package/dist/App.d.ts +2451 -1533
  11. package/dist/App.d.ts.map +1 -1
  12. package/dist/App.js +23 -9
  13. package/dist/App.js.map +1 -1
  14. package/dist/Assets.d.ts +10 -0
  15. package/dist/Assets.d.ts.map +1 -1
  16. package/dist/Assets.js +4 -0
  17. package/dist/Assets.js.map +1 -1
  18. package/dist/Auth.d.ts +2 -1
  19. package/dist/Auth.d.ts.map +1 -1
  20. package/dist/Auth.js +2 -1
  21. package/dist/Auth.js.map +1 -1
  22. package/dist/Scope.d.ts +40 -15
  23. package/dist/Scope.d.ts.map +1 -1
  24. package/dist/Scope.js +44 -13
  25. package/dist/Scope.js.map +1 -1
  26. package/dist/admin/App.d.ts +560 -9
  27. package/dist/admin/App.d.ts.map +1 -1
  28. package/dist/admin/App.js +38 -6
  29. package/dist/admin/App.js.map +1 -1
  30. package/dist/admin/apps/api-keys.d.ts +10 -24
  31. package/dist/admin/apps/api-keys.d.ts.map +1 -1
  32. package/dist/admin/apps/api-keys.js +10 -3
  33. package/dist/admin/apps/api-keys.js.map +1 -1
  34. package/dist/admin/apps/organizations.d.ts +833 -0
  35. package/dist/admin/apps/organizations.d.ts.map +1 -0
  36. package/dist/admin/apps/organizations.js +733 -0
  37. package/dist/admin/apps/organizations.js.map +1 -0
  38. package/dist/admin/apps/scopes.d.ts +8 -0
  39. package/dist/admin/apps/scopes.d.ts.map +1 -1
  40. package/dist/admin/ui.generated.d.ts +1 -1
  41. package/dist/admin/ui.generated.d.ts.map +1 -1
  42. package/dist/admin/ui.generated.js +53 -13
  43. package/dist/admin/ui.generated.js.map +1 -1
  44. package/dist/analytics/Analytics.d.ts +10 -0
  45. package/dist/analytics/Analytics.d.ts.map +1 -1
  46. package/dist/analytics/Analytics.js +8 -0
  47. package/dist/analytics/Analytics.js.map +1 -1
  48. package/dist/analytics/tables/requestEvents.d.ts +9 -0
  49. package/dist/analytics/tables/requestEvents.d.ts.map +1 -1
  50. package/dist/analytics/tables/requestEvents.js +14 -0
  51. package/dist/analytics/tables/requestEvents.js.map +1 -1
  52. package/dist/apps/data/App.d.ts +1354 -984
  53. package/dist/apps/data/App.d.ts.map +1 -1
  54. package/dist/apps/data/App.js +20 -3
  55. package/dist/apps/data/App.js.map +1 -1
  56. package/dist/apps/data/FundingRouteProviders.d.ts +94 -5
  57. package/dist/apps/data/FundingRouteProviders.d.ts.map +1 -1
  58. package/dist/apps/data/FundingRouteProviders.js +196 -5
  59. package/dist/apps/data/FundingRouteProviders.js.map +1 -1
  60. package/dist/apps/data/routes/activities.d.ts +38 -38
  61. package/dist/apps/data/routes/addresses.d.ts +24 -24
  62. package/dist/apps/data/routes/balances.d.ts +45 -45
  63. package/dist/apps/data/routes/blocks.d.ts +39 -39
  64. package/dist/apps/data/routes/coingecko.d.ts +176 -176
  65. package/dist/apps/data/routes/exchanges.d.ts +453 -453
  66. package/dist/apps/data/routes/fee-amm.d.ts +101 -101
  67. package/dist/apps/data/routes/funding.d.ts +730 -138
  68. package/dist/apps/data/routes/funding.d.ts.map +1 -1
  69. package/dist/apps/data/routes/funding.js +234 -0
  70. package/dist/apps/data/routes/funding.js.map +1 -1
  71. package/dist/apps/data/routes/indexer.d.ts +5 -5
  72. package/dist/apps/data/routes/rpc.d.ts +31 -31
  73. package/dist/apps/data/routes/verified-tokens.d.ts +81 -81
  74. package/dist/apps/management/App.d.ts +1156 -615
  75. package/dist/apps/management/App.d.ts.map +1 -1
  76. package/dist/apps/management/App.js +2 -2
  77. package/dist/apps/management/Billing.d.ts +3 -0
  78. package/dist/apps/management/Billing.d.ts.map +1 -1
  79. package/dist/apps/management/Billing.js +7 -2
  80. package/dist/apps/management/Billing.js.map +1 -1
  81. package/dist/apps/management/routes/api-keys.d.ts +106 -90
  82. package/dist/apps/management/routes/api-keys.d.ts.map +1 -1
  83. package/dist/apps/management/routes/api-keys.js +41 -30
  84. package/dist/apps/management/routes/api-keys.js.map +1 -1
  85. package/dist/apps/management/routes/billing.d.ts +278 -146
  86. package/dist/apps/management/routes/billing.d.ts.map +1 -1
  87. package/dist/apps/management/routes/billing.js +14 -14
  88. package/dist/apps/management/routes/billing.js.map +1 -1
  89. package/dist/apps/management/routes/invitations.d.ts +93 -20
  90. package/dist/apps/management/routes/invitations.d.ts.map +1 -1
  91. package/dist/apps/management/routes/invitations.js +12 -15
  92. package/dist/apps/management/routes/invitations.js.map +1 -1
  93. package/dist/apps/management/routes/me.d.ts +13 -13
  94. package/dist/apps/management/routes/members.d.ts +89 -18
  95. package/dist/apps/management/routes/members.d.ts.map +1 -1
  96. package/dist/apps/management/routes/members.js +10 -10
  97. package/dist/apps/management/routes/members.js.map +1 -1
  98. package/dist/apps/management/routes/orgs.d.ts +130 -70
  99. package/dist/apps/management/routes/orgs.d.ts.map +1 -1
  100. package/dist/apps/management/routes/orgs.js +19 -19
  101. package/dist/apps/management/routes/orgs.js.map +1 -1
  102. package/dist/apps/management/routes/projects.d.ts +137 -48
  103. package/dist/apps/management/routes/projects.d.ts.map +1 -1
  104. package/dist/apps/management/routes/projects.js +12 -13
  105. package/dist/apps/management/routes/projects.js.map +1 -1
  106. package/dist/apps/management/routes/scopes.d.ts +14 -28
  107. package/dist/apps/management/routes/scopes.d.ts.map +1 -1
  108. package/dist/apps/management/routes/scopes.js +6 -7
  109. package/dist/apps/management/routes/scopes.js.map +1 -1
  110. package/dist/apps/management/routes/usage.d.ts +63 -19
  111. package/dist/apps/management/routes/usage.d.ts.map +1 -1
  112. package/dist/apps/management/routes/usage.js +9 -10
  113. package/dist/apps/management/routes/usage.js.map +1 -1
  114. package/dist/apps/mcp.d.ts.map +1 -1
  115. package/dist/apps/mcp.js +35 -3
  116. package/dist/apps/mcp.js.map +1 -1
  117. package/dist/apps/relay/App.d.ts +26 -4
  118. package/dist/apps/relay/App.d.ts.map +1 -1
  119. package/dist/apps/relay/App.js +17 -14
  120. package/dist/apps/relay/App.js.map +1 -1
  121. package/dist/db/Db.d.ts +5 -3
  122. package/dist/db/Db.d.ts.map +1 -1
  123. package/dist/db/Db.js +52 -0
  124. package/dist/db/Db.js.map +1 -1
  125. package/dist/db/Schema.d.ts +13 -0
  126. package/dist/db/Schema.d.ts.map +1 -1
  127. package/dist/db/Schema.js +16 -1
  128. package/dist/db/Schema.js.map +1 -1
  129. package/dist/db/tables/adminAuditLogs.d.ts +27 -0
  130. package/dist/db/tables/adminAuditLogs.d.ts.map +1 -0
  131. package/dist/db/tables/adminAuditLogs.js +19 -0
  132. package/dist/db/tables/adminAuditLogs.js.map +1 -0
  133. package/dist/db/tables/invitations.d.ts +1 -1
  134. package/dist/db/tables/invitations.d.ts.map +1 -1
  135. package/dist/db/tables/memberships.d.ts +32 -4
  136. package/dist/db/tables/memberships.d.ts.map +1 -1
  137. package/dist/db/tables/memberships.js +31 -0
  138. package/dist/db/tables/memberships.js.map +1 -1
  139. package/dist/db/tables/organizations.d.ts +43 -1
  140. package/dist/db/tables/organizations.d.ts.map +1 -1
  141. package/dist/db/tables/organizations.js +103 -0
  142. package/dist/db/tables/organizations.js.map +1 -1
  143. package/dist/db/tables/projects.d.ts +25 -0
  144. package/dist/db/tables/projects.d.ts.map +1 -1
  145. package/dist/db/tables/projects.js +22 -0
  146. package/dist/db/tables/projects.js.map +1 -1
  147. package/dist/db/tables/webhookDeliveries.d.ts +26 -0
  148. package/dist/db/tables/webhookDeliveries.d.ts.map +1 -1
  149. package/dist/db/tables/webhookDeliveries.js +37 -0
  150. package/dist/db/tables/webhookDeliveries.js.map +1 -1
  151. package/dist/handlers/relay.d.ts +13 -10
  152. package/dist/handlers/relay.d.ts.map +1 -1
  153. package/dist/handlers/relay.js +46 -16
  154. package/dist/handlers/relay.js.map +1 -1
  155. package/dist/internal/Auth.d.ts +13 -18
  156. package/dist/internal/Auth.d.ts.map +1 -1
  157. package/dist/internal/Auth.js +78 -58
  158. package/dist/internal/Auth.js.map +1 -1
  159. package/dist/internal/Cursor.d.ts +1 -1
  160. package/dist/internal/Cursor.d.ts.map +1 -1
  161. package/dist/internal/Cursor.js +4 -0
  162. package/dist/internal/Cursor.js.map +1 -1
  163. package/dist/internal/EdgeCache.d.ts.map +1 -1
  164. package/dist/internal/EdgeCache.js +2 -0
  165. package/dist/internal/EdgeCache.js.map +1 -1
  166. package/dist/internal/FundingRoutes.d.ts +170 -0
  167. package/dist/internal/FundingRoutes.d.ts.map +1 -1
  168. package/dist/internal/FundingRoutes.js +68 -6
  169. package/dist/internal/FundingRoutes.js.map +1 -1
  170. package/dist/internal/Log.d.ts +16 -1
  171. package/dist/internal/Log.d.ts.map +1 -1
  172. package/dist/internal/Log.js +5 -3
  173. package/dist/internal/Log.js.map +1 -1
  174. package/package.json +2 -2
  175. package/src/ApiKey.ts +2 -3
  176. package/src/ApiKeys.ts +95 -17
  177. package/src/App.test.ts +44 -2
  178. package/src/App.ts +29 -9
  179. package/src/Assets.ts +8 -0
  180. package/src/Auth.test-d.ts +36 -0
  181. package/src/Auth.ts +20 -1
  182. package/src/Client.test-d.ts +64 -17
  183. package/src/Scope.test.ts +34 -0
  184. package/src/Scope.ts +53 -17
  185. package/src/admin/App.test.ts +33 -3
  186. package/src/admin/App.ts +49 -14
  187. package/src/admin/apps/api-keys.ts +10 -3
  188. package/src/admin/apps/organizations.test.ts +455 -0
  189. package/src/admin/apps/organizations.ts +953 -0
  190. package/src/admin/apps/scopes.test.ts +10 -0
  191. package/src/admin/ui/plugin.ts +1 -1
  192. package/src/admin/ui/src/components/MobileProfile.tsx +2 -0
  193. package/src/admin/ui/src/components/ThemeToggle.tsx +20 -0
  194. package/src/admin/ui/src/lib/api-keys.ts +36 -19
  195. package/src/admin/ui/src/lib/config.ts +2 -3
  196. package/src/admin/ui/src/lib/organizations.test.ts +89 -0
  197. package/src/admin/ui/src/lib/organizations.ts +262 -0
  198. package/src/admin/ui/src/lib/query-client.ts +3 -1
  199. package/src/admin/ui/src/lib/theme.ts +57 -0
  200. package/src/admin/ui/src/routeTree.gen.ts +47 -4
  201. package/src/admin/ui/src/routes/__root.tsx +18 -3
  202. package/src/admin/ui/src/routes/api-keys.tsx +52 -7
  203. package/src/admin/ui/src/routes/index.tsx +1 -1
  204. package/src/admin/ui/src/routes/organizations.tsx +158 -0
  205. package/src/admin/ui/src/routes/organizations_.$orgId.tsx +710 -0
  206. package/src/admin/ui/src/start.ts +6 -0
  207. package/src/admin/ui.generated.ts +63 -13
  208. package/src/analytics/Analytics.ts +25 -0
  209. package/src/analytics/tables/requestEvents.ts +33 -0
  210. package/src/apps/composition.test.ts +82 -2
  211. package/src/apps/data/App.ts +24 -3
  212. package/src/apps/data/FundingRouteProviders.test.ts +204 -0
  213. package/src/apps/data/FundingRouteProviders.ts +294 -9
  214. package/src/apps/data/routes/funding.test.ts +90 -23
  215. package/src/apps/data/routes/funding.ts +315 -0
  216. package/src/apps/data/routes/rpc.test.ts +33 -0
  217. package/src/apps/management/App.ts +2 -2
  218. package/src/apps/management/Billing.ts +10 -2
  219. package/src/apps/management/routes/api-keys.test.ts +137 -1
  220. package/src/apps/management/routes/api-keys.ts +63 -38
  221. package/src/apps/management/routes/billing.ts +14 -14
  222. package/src/apps/management/routes/invitations.test.ts +102 -1
  223. package/src/apps/management/routes/invitations.ts +13 -15
  224. package/src/apps/management/routes/members.ts +10 -10
  225. package/src/apps/management/routes/orgs.test.ts +78 -16
  226. package/src/apps/management/routes/orgs.ts +21 -20
  227. package/src/apps/management/routes/projects.ts +12 -13
  228. package/src/apps/management/routes/scopes.test.ts +16 -0
  229. package/src/apps/management/routes/scopes.ts +6 -7
  230. package/src/apps/management/routes/usage.ts +9 -14
  231. package/src/apps/mcp.test.ts +25 -1
  232. package/src/apps/mcp.ts +43 -3
  233. package/src/apps/relay/App.test.ts +23 -18
  234. package/src/apps/relay/App.ts +35 -14
  235. package/src/db/Db.ts +59 -3
  236. package/src/db/Schema.ts +20 -1
  237. package/src/db/tables/adminAuditLogs.ts +47 -0
  238. package/src/db/tables/invitations.ts +1 -1
  239. package/src/db/tables/memberships.ts +59 -3
  240. package/src/db/tables/organizations.ts +148 -1
  241. package/src/db/tables/projects.ts +47 -0
  242. package/src/db/tables/webhookDeliveries.ts +83 -0
  243. package/src/handlers/relay.test-d.ts +8 -0
  244. package/src/handlers/relay.test.ts +2 -2
  245. package/src/handlers/relay.ts +62 -22
  246. package/src/internal/Auth.test.ts +174 -7
  247. package/src/internal/Auth.ts +90 -62
  248. package/src/internal/Cursor.test.ts +10 -0
  249. package/src/internal/Cursor.ts +5 -1
  250. package/src/internal/EdgeCache.test.ts +11 -0
  251. package/src/internal/EdgeCache.ts +2 -0
  252. package/src/internal/FundingRoutes.test.ts +26 -0
  253. package/src/internal/FundingRoutes.ts +187 -18
  254. package/src/internal/Log.test.ts +41 -3
  255. package/src/internal/Log.ts +23 -4
@@ -29,14 +29,14 @@ const senderAccount = Tempo.accounts[9]!
29
29
  /** Super admin secret configured on the end-to-end billing app. */
30
30
  const superAdminSecret = 'tempo:sk:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1'
31
31
 
32
- /** Keys covering the sponsorship policy matrix; all hold the base relay scope. */
32
+ /** Keys covering the sponsorship policy matrix. */
33
33
  const keys = [
34
34
  {
35
35
  id: 'key_sponsor',
36
36
  orgId: 'org_1',
37
37
  projectId: 'prj_1',
38
38
  environment: 'sandbox',
39
- scopes: ['rpc-relay:read', 'rpc-relay:sponsor'],
39
+ scopes: ['rpc-relay:sponsor'],
40
40
  token: 'secret_sponsor',
41
41
  },
42
42
  {
@@ -44,7 +44,7 @@ const keys = [
44
44
  orgId: 'org_1',
45
45
  projectId: 'prj_1',
46
46
  environment: 'production',
47
- scopes: ['rpc-relay:read', 'rpc-relay:sponsor'],
47
+ scopes: ['rpc-relay:sponsor'],
48
48
  token: 'secret_sponsor_prod',
49
49
  },
50
50
  {
@@ -75,7 +75,7 @@ const keys = [
75
75
  id: 'key_unattributed',
76
76
  orgId: 'org_1',
77
77
  environment: 'sandbox',
78
- scopes: ['rpc-relay:read', 'rpc-relay:sponsor'],
78
+ scopes: ['rpc-relay:sponsor'],
79
79
  token: 'secret_unattributed',
80
80
  },
81
81
  ] as const satisfies readonly TestApp.kvStore.Key[]
@@ -169,7 +169,7 @@ describe.skipIf(runtime.mode !== 'localnet')('behavior: sponsorship', () => {
169
169
  expect(row?.orgId).toMatchInlineSnapshot(`"org_2"`)
170
170
  })
171
171
 
172
- test('behavior: keys without the sponsor scope are refused', async () => {
172
+ test('behavior: read-only keys cannot access the sponsor route', async () => {
173
173
  const db = TestApp.database()
174
174
  const app = TestApp.create({
175
175
  auth: { keys },
@@ -177,14 +177,15 @@ describe.skipIf(runtime.mode !== 'localnet')('behavior: sponsorship', () => {
177
177
  relay: { feePayer: { account: feePayerAccount } },
178
178
  })
179
179
 
180
- const body = await relayRequest(app, {
181
- method: 'eth_signRawTransaction',
182
- params: [await signSponsorable()],
183
- token: 'secret_unscoped',
184
- })
185
- // "Sponsorship rejected." surfaces as invalid params (-32602).
186
- expect(body.result).toBeUndefined()
187
- expect(body.error?.code).toBe(-32602)
180
+ const response = await app.fetch(
181
+ new Request('http://tempo-api.test/rpc/sponsor', {
182
+ body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }),
183
+ headers: { 'content-type': 'application/json', 'tempo-api-key': 'secret_unscoped' },
184
+ method: 'POST',
185
+ }),
186
+ )
187
+ expect(response.status).toBe(403)
188
+ expect(await response.json()).toMatchObject({ error: { code: 'api_key_forbidden' } })
188
189
  expect(await SponsoredTransactions.listPending(db)).toHaveLength(0)
189
190
  })
190
191
 
@@ -288,12 +289,16 @@ describe.skipIf(runtime.mode !== 'localnet')('behavior: sponsorship', () => {
288
289
  nonce: '0x0',
289
290
  },
290
291
  ],
291
- token: 'secret_unscoped',
292
+ token: 'secret_sponsor',
292
293
  })
293
294
  const result = body.result as unknown as
294
- | { capabilities?: { sponsored?: boolean }; tx?: { feePayerSignature?: string | null } }
295
+ | {
296
+ capabilities?: { balanceDiffs?: unknown; sponsored?: boolean }
297
+ tx?: { feePayerSignature?: string | null }
298
+ }
295
299
  | undefined
296
300
  expect(result?.tx?.feePayerSignature ?? null).toBeNull()
301
+ expect(result?.capabilities?.balanceDiffs).toBeUndefined()
297
302
  expect(result?.capabilities?.sponsored).toBe(false)
298
303
  expect(await SponsoredTransactions.listPending(db)).toHaveLength(0)
299
304
  })
@@ -629,7 +634,7 @@ describe.skipIf(runtime.mode !== 'localnet')('behavior: sponsorship (viem)', ()
629
634
  chain: Tempo.chain,
630
635
  transport: withRelay(
631
636
  Relay.http(),
632
- http(`${server.url}/rpc/relay`, {
637
+ http(`${server.url}/rpc/sponsor`, {
633
638
  fetchOptions: { headers: { 'tempo-api-key': 'secret_sponsor' } },
634
639
  }),
635
640
  policy ? { policy } : {},
@@ -785,13 +790,13 @@ async function signSponsorable(
785
790
  })
786
791
  }
787
792
 
788
- /** Posts one JSON-RPC request to the composed app's relay mount. */
793
+ /** Posts one JSON-RPC request to the composed app's sponsor mount. */
789
794
  async function relayRequest(
790
795
  app: ReturnType<typeof TestApp.create>,
791
796
  options: { method: string; params: readonly unknown[]; token?: string | undefined },
792
797
  ) {
793
798
  const response = await app.fetch(
794
- new Request('http://tempo-api.test/rpc/relay', {
799
+ new Request('http://tempo-api.test/rpc/sponsor', {
795
800
  body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: options.method, params: options.params }), // prettier-ignore
796
801
  headers: {
797
802
  'content-type': 'application/json',
@@ -18,16 +18,17 @@ import * as Viem from '../../internal/Viem.js'
18
18
  import * as Scope from '../../Scope.js'
19
19
 
20
20
  /**
21
- * The relay route group: the wallet relay at `/rpc/relay`. Mount
22
- * after `data()`, whose published chain clients and supported-chain set the
23
- * middleware reads from request context.
21
+ * The relay route group exposes wallet features at `/rpc/relay` and fee
22
+ * sponsorship at `/rpc/sponsor`. Mount it after `data()`, which publishes
23
+ * chain clients and supported chains.
24
24
  *
25
25
  * ```ts
26
26
  * App.create(options).route('/', data()).route('/', relay())
27
27
  * ```
28
28
  */
29
29
  export function relay(options: relay.Options = {}) {
30
- const handler = middleware(options)
30
+ const handler_relay = middleware({ ...options, features: 'all', path: 'rpc/relay' })
31
+ const handler_sponsor = middleware({ ...options, path: 'rpc/sponsor' })
31
32
  return new Hono<App.Environment>()
32
33
  .use(
33
34
  '/rpc/relay',
@@ -39,8 +40,20 @@ export function relay(options: relay.Options = {}) {
39
40
  Auth.policy({ apiKey: { scopes: ['rpc-relay:read'] }, mpp: false, public: false }),
40
41
  OpenApi.describeRoute({ hide: true, summary: 'Relay RPC' }),
41
42
  )
42
- .all('/rpc/relay', handler)
43
- .all('/rpc/relay/:chainId', handler)
43
+ .all('/rpc/relay', handler_relay)
44
+ .all('/rpc/relay/:chainId', handler_relay)
45
+ .use(
46
+ '/rpc/sponsor',
47
+ Auth.policy({ apiKey: { scopes: ['rpc-relay:sponsor'] }, mpp: false, public: false }),
48
+ OpenApi.describeRoute({ hide: true, summary: 'Sponsor RPC' }),
49
+ )
50
+ .use(
51
+ '/rpc/sponsor/*',
52
+ Auth.policy({ apiKey: { scopes: ['rpc-relay:sponsor'] }, mpp: false, public: false }),
53
+ OpenApi.describeRoute({ hide: true, summary: 'Sponsor RPC' }),
54
+ )
55
+ .all('/rpc/sponsor', handler_sponsor)
56
+ .all('/rpc/sponsor/:chainId', handler_sponsor)
44
57
  }
45
58
 
46
59
  export declare namespace relay {
@@ -58,11 +71,9 @@ export declare namespace relay {
58
71
  }
59
72
  }
60
73
 
61
- // The wallet relay middleware: proxies wallet JSON-RPC (fills, sponsored
62
- // sends) with wallet-aware enrichment. Built per request from context rather
63
- // than closed over group locals; relay traffic is low-QPS and the viem
64
- // clients are memoized upstream, so per-request handler construction is cheap.
65
- function middleware(options: relay.Options = {}) {
74
+ // Builds request-aware RPC middleware. Viem clients are memoized upstream, so
75
+ // constructing `Handler.relay` per request stays cheap.
76
+ function middleware(options: middleware.Options) {
66
77
  // Platform-wide per-transaction cap; org settings tighten it, never loosen.
67
78
  const txFeeLimit = options.feePayer
68
79
  ? Billing.toBaseUnits(options.feePayer.txFeeLimit ?? '1.00')
@@ -215,12 +226,12 @@ function middleware(options: relay.Options = {}) {
215
226
 
216
227
  // Dispatching a standalone Hono via `fetch(c.req.raw)` bypasses Hono's
217
228
  // mount-path stripping, so its internal routes register at the request's
218
- // full external path (`basePath` + `/rpc/relay`).
229
+ // full external path (`basePath` + the RPC mount).
219
230
  const handler = Handler.relay({
220
231
  cache: c.get('store'),
221
- features: 'all',
232
+ ...(options.features ? { features: options.features } : {}),
222
233
  getClient: (chainId) => c.get('getClient')(chainId as Viem.ChainId | undefined),
223
- path: Path.join(c.get('basePath'), 'rpc/relay'),
234
+ path: Path.join(c.get('basePath'), options.path),
224
235
  ...(feePayer ? { feePayer } : {}),
225
236
  ...(verifiedTokens
226
237
  ? {
@@ -234,3 +245,13 @@ function middleware(options: relay.Options = {}) {
234
245
  return handler.fetch(c.req.raw)
235
246
  })
236
247
  }
248
+
249
+ declare namespace middleware {
250
+ /** Internal relay mount configuration. */
251
+ type Options = relay.Options & {
252
+ /** Optional wallet feature bundle. */
253
+ features?: Handler.relay.Options['features']
254
+ /** RPC mount path. */
255
+ path: 'rpc/relay' | 'rpc/sponsor'
256
+ }
257
+ }
package/src/db/Db.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  } from 'kysely'
11
11
  import * as pg from 'pg'
12
12
 
13
+ import type * as AdminAuditLogs from './tables/adminAuditLogs.js'
13
14
  import type * as BillingSettings from './tables/billingSettings.js'
14
15
  import type * as Invitations from './tables/invitations.js'
15
16
  import type * as Memberships from './tables/memberships.js'
@@ -28,6 +29,10 @@ import type * as WebhookSubscriptions from './tables/webhookSubscriptions.js'
28
29
  * records, queries, and {@link migrations} DDL to snake_case storage columns.
29
30
  */
30
31
  export type Database = {
32
+ admin_audit_logs: AdminAuditLogs.Table
33
+ billing_settings: BillingSettings.Table
34
+ invitations: Invitations.Table
35
+ memberships: Memberships.Table
31
36
  migrations: {
32
37
  appliedAt: string
33
38
  checksum: string
@@ -35,9 +40,6 @@ export type Database = {
35
40
  /** SQL as applied; null on rows written before the column existed. */
36
41
  sql: string | null
37
42
  }
38
- billing_settings: BillingSettings.Table
39
- invitations: Invitations.Table
40
- memberships: Memberships.Table
41
43
  organizations: Organizations.Table
42
44
  projects: Projects.Table
43
45
  request_usage_buckets: RequestUsage.BucketTable
@@ -520,6 +522,60 @@ export const migrations: readonly Migration[] = [
520
522
  // Partial: the reporter scans only unreported (pending) rows.
521
523
  up: (db) => raw(db, sql`CREATE INDEX IF NOT EXISTS request_usage_meter_events_pending_idx ON request_usage_meter_events (first_attempted_at, identifier) WHERE state = 'pending'`), // prettier-ignore
522
524
  },
525
+ {
526
+ // Supports the admin organization's newest-first keyset pagination.
527
+ name: '0045_organizations_admin_list_index',
528
+ up: (db) => raw(db, sql`CREATE INDEX IF NOT EXISTS organizations_admin_list_idx ON organizations (created_at DESC, id DESC)`), // prettier-ignore
529
+ },
530
+ {
531
+ // Prefix search keeps organization-name lookup index-backed.
532
+ name: '0046_organizations_name_prefix_index',
533
+ up: (db) => raw(db, sql`CREATE INDEX IF NOT EXISTS organizations_name_prefix_idx ON organizations (lower(name) text_pattern_ops)`), // prettier-ignore
534
+ },
535
+ {
536
+ // Prefix search keeps member-email lookup index-backed.
537
+ name: '0047_users_email_prefix_index',
538
+ up: (db) => raw(db, sql`CREATE INDEX IF NOT EXISTS users_email_prefix_idx ON users (lower(email) text_pattern_ops) WHERE email IS NOT NULL`), // prettier-ignore
539
+ },
540
+ {
541
+ // Exact owner lookup for organizations created by a user.
542
+ name: '0048_organizations_user_index',
543
+ up: (db) => raw(db, sql`CREATE INDEX IF NOT EXISTS organizations_user_idx ON organizations (user_id) WHERE user_id IS NOT NULL`), // prettier-ignore
544
+ },
545
+ {
546
+ // Supports oldest-first admin membership keyset pagination.
547
+ name: '0049_memberships_admin_list_index',
548
+ up: (db) => raw(db, sql`CREATE INDEX IF NOT EXISTS memberships_admin_list_idx ON memberships (org_id, created_at, user_id)`), // prettier-ignore
549
+ },
550
+ {
551
+ // Supports newest-first admin project keyset pagination.
552
+ name: '0050_projects_admin_list_index',
553
+ up: (db) => raw(db, sql`CREATE INDEX IF NOT EXISTS projects_admin_list_idx ON projects (org_id, created_at DESC, id DESC)`), // prettier-ignore
554
+ },
555
+ {
556
+ name: '0051_admin_audit_logs',
557
+ up: (db) =>
558
+ db.schema
559
+ .createTable('adminAuditLogs')
560
+ .ifNotExists()
561
+ .addColumn('id', 'text', (c) => c.primaryKey())
562
+ .addColumn('actor', 'text', (c) => c.notNull())
563
+ .addColumn('method', 'text', (c) => c.notNull())
564
+ .addColumn('path', 'text', (c) => c.notNull())
565
+ .addColumn('query', 'text')
566
+ .addColumn('requestId', 'text', (c) => c.notNull())
567
+ .addColumn('status', 'integer', (c) => c.notNull())
568
+ .addColumn('createdAt', 'text', (c) => c.notNull()),
569
+ },
570
+ {
571
+ name: '0052_admin_audit_logs_created_index',
572
+ up: (db) =>
573
+ db.schema
574
+ .createIndex('admin_audit_logs_created_idx')
575
+ .ifNotExists()
576
+ .on('adminAuditLogs')
577
+ .columns(['createdAt', 'id']),
578
+ },
523
579
  ]
524
580
 
525
581
  /**
package/src/db/Schema.ts CHANGED
@@ -2,6 +2,25 @@ import * as z from 'zod/mini'
2
2
 
3
3
  import type * as Webhooks from '../internal/Webhooks.js'
4
4
 
5
+ /** One authenticated administrator access to a protected admin API route. */
6
+ export const AdminAuditLog = z.object({
7
+ actor: z.string().check(z.describe('Verified administrator email.')),
8
+ createdAt: z.string().check(z.describe('When the access completed (ISO 8601).')),
9
+ id: z.string().check(z.describe('Opaque audit event id (`aud_…`).')),
10
+ method: z.string().check(z.describe('HTTP method used for the access.')),
11
+ path: z.string().check(z.describe('Admin API path accessed, without query parameters.')),
12
+ query: z
13
+ .nullable(z.string())
14
+ .check(z.describe('Query string used for the lookup, or null when absent.')),
15
+ requestId: z.string().check(z.describe('Request id correlating the access with request logs.')),
16
+ status: z
17
+ .number()
18
+ .check(z.int(), z.describe('HTTP response status returned to the administrator.')),
19
+ })
20
+
21
+ /** Columns of the `admin_audit_logs` table. */
22
+ export type AdminAuditLog = z.output<typeof AdminAuditLog>
23
+
5
24
  // Row schemas for database tables (`zod/mini`), the source of truth each
6
25
  // `tables/` module derives its `Table` type from. Nullable fields map to
7
26
  // nullable columns; the domain layer converts `null` back to absent fields.
@@ -49,7 +68,7 @@ export const Invitation = z.object({
49
68
  z.describe('Opaque invitation id (`inv_…`).'),
50
69
  z.meta({ examples: ['inv_1a2b3c4d5e6f7g8h9j0k1m2n'] }),
51
70
  ),
52
- invitedBy: z.string().check(z.describe('User id (`usr_…`) that created the invitation.')),
71
+ invitedBy: z.string().check(z.describe('User or API key id that created the invitation.')),
53
72
  orgId: z.string().check(z.describe('Organization id (`org_…`) the invitation joins.')),
54
73
  revokedAt: z
55
74
  .nullable(z.string())
@@ -0,0 +1,47 @@
1
+ import type { Selectable } from 'kysely'
2
+
3
+ import * as Id from '../../internal/Id.js'
4
+ import type * as Db from '../Db.js'
5
+ import type * as db_Schema from '../Schema.js'
6
+
7
+ /** Columns of the `admin_audit_logs` table. */
8
+ export type Table = db_Schema.AdminAuditLog
9
+
10
+ /** One stored administrator access event. */
11
+ export type Record = Selectable<Table>
12
+
13
+ /** Records an authenticated access to a protected admin API route. */
14
+ export function insert(db: Db.Db, input: insert.Input): Promise<Record> {
15
+ return db.kysely
16
+ .insertInto('admin_audit_logs')
17
+ .values({
18
+ actor: input.actor,
19
+ createdAt: new Date().toISOString(),
20
+ id: Id.generate('aud'),
21
+ method: input.method,
22
+ path: input.path,
23
+ query: input.query ?? null,
24
+ requestId: input.requestId,
25
+ status: input.status,
26
+ })
27
+ .returningAll()
28
+ .executeTakeFirstOrThrow()
29
+ }
30
+
31
+ export declare namespace insert {
32
+ /** Fields captured for one administrator access. */
33
+ type Input = {
34
+ /** Verified administrator email. */
35
+ actor: string
36
+ /** HTTP method used for the access. */
37
+ method: string
38
+ /** Admin API path accessed, without query parameters. */
39
+ path: string
40
+ /** Query string used for the lookup. */
41
+ query?: string | undefined
42
+ /** Request id correlating the access with request logs. */
43
+ requestId: string
44
+ /** HTTP response status returned to the administrator. */
45
+ status: number
46
+ }
47
+ }
@@ -60,7 +60,7 @@ export declare namespace create {
60
60
  email: string
61
61
  /** When the invitation expires (ISO 8601). */
62
62
  expiresAt: string
63
- /** User id (`usr_…`) creating the invitation. */
63
+ /** User or API key id creating the invitation. */
64
64
  invitedBy: string
65
65
  /** Organization id (`org_…`) the invitation joins. */
66
66
  orgId: string
@@ -9,6 +9,9 @@ export type Table = db_Schema.Membership
9
9
  /** A stored membership row. */
10
10
  export type Record = Selectable<Table>
11
11
 
12
+ /** A membership with the member's resolved user identity. */
13
+ export type DetailedRecord = Record & { address: string | null; email: string | null }
14
+
12
15
  /** A membership role. */
13
16
  export type Role = Record['role']
14
17
 
@@ -93,20 +96,73 @@ export function listByOrg(db: Db.Db, orgId: string): Promise<Record[]> {
93
96
  * @param orgId - The organization id (`org_…`).
94
97
  * @returns The records with `address`/`email` from the member's user row.
95
98
  */
96
- export function listByOrgDetailed(
99
+ export function listByOrgDetailed(db: Db.Db, orgId: string): Promise<DetailedRecord[]> {
100
+ return db.kysely
101
+ .selectFrom('memberships')
102
+ .innerJoin('users', 'users.id', 'memberships.userId')
103
+ .where('memberships.orgId', '=', orgId)
104
+ .selectAll('memberships')
105
+ .select(['users.address', 'users.email'])
106
+ .orderBy('memberships.createdAt', 'asc')
107
+ .execute()
108
+ }
109
+
110
+ /**
111
+ * Lists a bounded page of an organization's memberships with member identity,
112
+ * oldest first.
113
+ *
114
+ * @param db - The database.
115
+ * @param orgId - The organization id.
116
+ * @param options - Keyset and page-size options.
117
+ * @returns At most `limit + 1` rows for next-cursor derivation.
118
+ */
119
+ export function listByOrgDetailedPage(
97
120
  db: Db.Db,
98
121
  orgId: string,
99
- ): Promise<(Record & { address: string | null; email: string | null })[]> {
100
- return db.kysely
122
+ options: listByOrgDetailedPage.Options,
123
+ ): Promise<DetailedRecord[]> {
124
+ const cursor = options.cursor
125
+ let query = db.kysely
101
126
  .selectFrom('memberships')
102
127
  .innerJoin('users', 'users.id', 'memberships.userId')
103
128
  .where('memberships.orgId', '=', orgId)
104
129
  .selectAll('memberships')
105
130
  .select(['users.address', 'users.email'])
131
+ if (cursor)
132
+ query = query.where((eb) =>
133
+ eb.or([
134
+ eb('memberships.createdAt', '>', cursor.createdAt),
135
+ eb.and([
136
+ eb('memberships.createdAt', '=', cursor.createdAt),
137
+ eb('memberships.userId', '>', cursor.userId),
138
+ ]),
139
+ ]),
140
+ )
141
+ return query
106
142
  .orderBy('memberships.createdAt', 'asc')
143
+ .orderBy('memberships.userId', 'asc')
144
+ .limit(options.limit + 1)
107
145
  .execute()
108
146
  }
109
147
 
148
+ export declare namespace listByOrgDetailedPage {
149
+ /** Cursor fields for the last membership on the previous page. */
150
+ type Cursor = {
151
+ /** Membership creation time. */
152
+ createdAt: string
153
+ /** Member user id, used as a deterministic tie-breaker. */
154
+ userId: string
155
+ }
156
+
157
+ /** Options for {@link listByOrgDetailedPage}. */
158
+ type Options = {
159
+ /** Last membership returned by the previous page. */
160
+ cursor?: Cursor | undefined
161
+ /** Requested page size. */
162
+ limit: number
163
+ }
164
+ }
165
+
110
166
  /**
111
167
  * Changes a member's role atomically, refusing to demote the last owner. The
112
168
  * org's owner rows are locked for the transaction, so a concurrent demotion or
@@ -1,4 +1,4 @@
1
- import type { Selectable } from 'kysely'
1
+ import { sql, type Selectable } from 'kysely'
2
2
 
3
3
  import * as Id from '../../internal/Id.js'
4
4
  import type * as Db from '../Db.js'
@@ -133,6 +133,39 @@ export function get(db: Db.Db, id: string): Promise<Record | undefined> {
133
133
  return db.kysely.selectFrom('organizations').selectAll().where('id', '=', id).executeTakeFirst()
134
134
  }
135
135
 
136
+ /**
137
+ * Reads an organization with current member and project counts.
138
+ *
139
+ * @param db - The database.
140
+ * @param id - The organization id.
141
+ * @returns The organization summary, or `undefined` when absent.
142
+ */
143
+ export async function getSummary(db: Db.Db, id: string): Promise<search.Result | undefined> {
144
+ const record = await db.kysely
145
+ .selectFrom('organizations')
146
+ .selectAll('organizations')
147
+ .select((eb) => [
148
+ eb
149
+ .selectFrom('memberships')
150
+ .select((builder) => builder.fn.countAll<number>().as('count'))
151
+ .whereRef('memberships.orgId', '=', 'organizations.id')
152
+ .as('memberCount'),
153
+ eb
154
+ .selectFrom('projects')
155
+ .select((builder) => builder.fn.countAll<number>().as('count'))
156
+ .whereRef('projects.orgId', '=', 'organizations.id')
157
+ .as('projectCount'),
158
+ ])
159
+ .where('organizations.id', '=', id)
160
+ .executeTakeFirst()
161
+ if (!record) return undefined
162
+ return {
163
+ ...record,
164
+ memberCount: Number(record.memberCount),
165
+ projectCount: Number(record.projectCount),
166
+ }
167
+ }
168
+
136
169
  /**
137
170
  * Lists organizations, newest first.
138
171
  *
@@ -143,6 +176,120 @@ export function list(db: Db.Db): Promise<Record[]> {
143
176
  return db.kysely.selectFrom('organizations').selectAll().orderBy('createdAt', 'desc').execute()
144
177
  }
145
178
 
179
+ /**
180
+ * Searches organizations for the admin lookup surface, newest first. Results
181
+ * include member and project counts and use a stable `(createdAt, id)` keyset.
182
+ *
183
+ * @param db - The database.
184
+ * @param options - Search, keyset, and page-size options.
185
+ * @returns At most `limit + 1` rows so the caller can derive a next cursor.
186
+ */
187
+ export async function search(db: Db.Db, options: search.Options): Promise<search.Result[]> {
188
+ const { cursor, limit, query: searchQuery } = options
189
+ let query = db.kysely
190
+ .selectFrom('organizations')
191
+ .selectAll('organizations')
192
+ .select((eb) => [
193
+ eb
194
+ .selectFrom('memberships')
195
+ .select((builder) => builder.fn.countAll<number>().as('count'))
196
+ .whereRef('memberships.orgId', '=', 'organizations.id')
197
+ .as('memberCount'),
198
+ eb
199
+ .selectFrom('projects')
200
+ .select((builder) => builder.fn.countAll<number>().as('count'))
201
+ .whereRef('projects.orgId', '=', 'organizations.id')
202
+ .as('projectCount'),
203
+ ])
204
+
205
+ if (searchQuery?.startsWith('org_')) query = query.where('organizations.id', '=', searchQuery)
206
+ else if (searchQuery?.startsWith('usr_'))
207
+ query = query.where((eb) =>
208
+ eb.or([
209
+ eb('organizations.userId', '=', searchQuery),
210
+ eb(
211
+ 'organizations.id',
212
+ 'in',
213
+ eb
214
+ .selectFrom('memberships')
215
+ .select('memberships.orgId')
216
+ .where('memberships.userId', '=', searchQuery),
217
+ ),
218
+ ]),
219
+ )
220
+ else if (searchQuery) {
221
+ const pattern = `${searchQuery
222
+ .toLowerCase()
223
+ .replaceAll('\\', '\\\\')
224
+ .replaceAll('%', '\\%')
225
+ .replaceAll('_', '\\_')}%`
226
+ query = query.where((eb) =>
227
+ eb.or([
228
+ sql<boolean>`lower(${sql.ref('organizations.name')}) LIKE ${pattern} ESCAPE '\\'`,
229
+ eb(
230
+ 'organizations.id',
231
+ 'in',
232
+ eb
233
+ .selectFrom('memberships')
234
+ .innerJoin('users', 'users.id', 'memberships.userId')
235
+ .select('memberships.orgId')
236
+ .where(sql<boolean>`lower(${sql.ref('users.email')}) LIKE ${pattern} ESCAPE '\\'`),
237
+ ),
238
+ ]),
239
+ )
240
+ }
241
+
242
+ if (cursor)
243
+ query = query.where((eb) =>
244
+ eb.or([
245
+ eb('organizations.createdAt', '<', cursor.createdAt),
246
+ eb.and([
247
+ eb('organizations.createdAt', '=', cursor.createdAt),
248
+ eb('organizations.id', '<', cursor.id),
249
+ ]),
250
+ ]),
251
+ )
252
+
253
+ const records = await query
254
+ .orderBy('organizations.createdAt', 'desc')
255
+ .orderBy('organizations.id', 'desc')
256
+ .limit(limit + 1)
257
+ .execute()
258
+ return records.map((record) => ({
259
+ ...record,
260
+ memberCount: Number(record.memberCount),
261
+ projectCount: Number(record.projectCount),
262
+ }))
263
+ }
264
+
265
+ export declare namespace search {
266
+ /** Cursor fields for the last organization returned by the previous page. */
267
+ type Cursor = {
268
+ /** Organization creation time. */
269
+ createdAt: string
270
+ /** Organization id, used as a deterministic tie-breaker. */
271
+ id: string
272
+ }
273
+
274
+ /** Options for {@link search}. */
275
+ type Options = {
276
+ /** Last organization returned by the previous page. */
277
+ cursor?: Cursor | undefined
278
+ /** Requested page size. */
279
+ limit: number
280
+ /** Exact resource id or case-insensitive name/email prefix search. */
281
+ query?: string | undefined
282
+ }
283
+
284
+ /** Organization summary returned by {@link search}. */
285
+ type Result = Record & {
286
+ /** Number of current organization members. */
287
+ memberCount: number
288
+ /** Number of projects owned by the organization. */
289
+ projectCount: number
290
+ }
291
+ }
292
+
146
293
  /**
147
294
  * Lists organizations owned by `userId`, newest first.
148
295
  *