tapimo 0.4.1 → 0.4.4

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 (263) 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 +1990 -1442
  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/apps/verified-tokens.d.ts +57 -57
  41. package/dist/admin/ui.generated.d.ts +1 -1
  42. package/dist/admin/ui.generated.d.ts.map +1 -1
  43. package/dist/admin/ui.generated.js +53 -13
  44. package/dist/admin/ui.generated.js.map +1 -1
  45. package/dist/analytics/Analytics.d.ts +10 -0
  46. package/dist/analytics/Analytics.d.ts.map +1 -1
  47. package/dist/analytics/Analytics.js +8 -0
  48. package/dist/analytics/Analytics.js.map +1 -1
  49. package/dist/analytics/tables/requestEvents.d.ts +9 -0
  50. package/dist/analytics/tables/requestEvents.d.ts.map +1 -1
  51. package/dist/analytics/tables/requestEvents.js +14 -0
  52. package/dist/analytics/tables/requestEvents.js.map +1 -1
  53. package/dist/apps/data/App.d.ts +1304 -1304
  54. package/dist/apps/data/FundingRouteProviders.d.ts +4 -22
  55. package/dist/apps/data/FundingRouteProviders.d.ts.map +1 -1
  56. package/dist/apps/data/FundingRouteProviders.js +10 -120
  57. package/dist/apps/data/FundingRouteProviders.js.map +1 -1
  58. package/dist/apps/data/routes/activities.d.ts +38 -38
  59. package/dist/apps/data/routes/addresses.d.ts +24 -24
  60. package/dist/apps/data/routes/blocks.d.ts +39 -39
  61. package/dist/apps/data/routes/fee-amm.d.ts +20 -20
  62. package/dist/apps/data/routes/funding.js +7 -7
  63. package/dist/apps/data/routes/funding.js.map +1 -1
  64. package/dist/apps/data/routes/indexer.d.ts +5 -5
  65. package/dist/apps/data/routes/tokenlist.d.ts +12 -12
  66. package/dist/apps/data/routes/verified-tokens.d.ts +36 -36
  67. package/dist/apps/management/App.d.ts +808 -267
  68. package/dist/apps/management/App.d.ts.map +1 -1
  69. package/dist/apps/management/App.js +2 -2
  70. package/dist/apps/management/Billing.d.ts +3 -0
  71. package/dist/apps/management/Billing.d.ts.map +1 -1
  72. package/dist/apps/management/Billing.js +7 -2
  73. package/dist/apps/management/Billing.js.map +1 -1
  74. package/dist/apps/management/routes/api-keys.d.ts +116 -100
  75. package/dist/apps/management/routes/api-keys.d.ts.map +1 -1
  76. package/dist/apps/management/routes/api-keys.js +41 -30
  77. package/dist/apps/management/routes/api-keys.js.map +1 -1
  78. package/dist/apps/management/routes/billing.d.ts +182 -50
  79. package/dist/apps/management/routes/billing.d.ts.map +1 -1
  80. package/dist/apps/management/routes/billing.js +14 -14
  81. package/dist/apps/management/routes/billing.js.map +1 -1
  82. package/dist/apps/management/routes/invitations.d.ts +93 -20
  83. package/dist/apps/management/routes/invitations.d.ts.map +1 -1
  84. package/dist/apps/management/routes/invitations.js +12 -15
  85. package/dist/apps/management/routes/invitations.js.map +1 -1
  86. package/dist/apps/management/routes/members.d.ts +89 -18
  87. package/dist/apps/management/routes/members.d.ts.map +1 -1
  88. package/dist/apps/management/routes/members.js +10 -10
  89. package/dist/apps/management/routes/members.js.map +1 -1
  90. package/dist/apps/management/routes/orgs.d.ts +100 -40
  91. package/dist/apps/management/routes/orgs.d.ts.map +1 -1
  92. package/dist/apps/management/routes/orgs.js +19 -19
  93. package/dist/apps/management/routes/orgs.js.map +1 -1
  94. package/dist/apps/management/routes/projects.d.ts +122 -33
  95. package/dist/apps/management/routes/projects.d.ts.map +1 -1
  96. package/dist/apps/management/routes/projects.js +12 -13
  97. package/dist/apps/management/routes/projects.js.map +1 -1
  98. package/dist/apps/management/routes/scopes.d.ts +4 -18
  99. package/dist/apps/management/routes/scopes.d.ts.map +1 -1
  100. package/dist/apps/management/routes/scopes.js +6 -7
  101. package/dist/apps/management/routes/scopes.js.map +1 -1
  102. package/dist/apps/management/routes/usage.d.ts +63 -19
  103. package/dist/apps/management/routes/usage.d.ts.map +1 -1
  104. package/dist/apps/management/routes/usage.js +9 -10
  105. package/dist/apps/management/routes/usage.js.map +1 -1
  106. package/dist/apps/mcp.d.ts.map +1 -1
  107. package/dist/apps/mcp.js +38 -4
  108. package/dist/apps/mcp.js.map +1 -1
  109. package/dist/apps/relay/App.d.ts +26 -4
  110. package/dist/apps/relay/App.d.ts.map +1 -1
  111. package/dist/apps/relay/App.js +17 -14
  112. package/dist/apps/relay/App.js.map +1 -1
  113. package/dist/cloudflare.d.ts +2 -0
  114. package/dist/cloudflare.d.ts.map +1 -1
  115. package/dist/cloudflare.js +4 -0
  116. package/dist/cloudflare.js.map +1 -1
  117. package/dist/db/Db.d.ts +5 -3
  118. package/dist/db/Db.d.ts.map +1 -1
  119. package/dist/db/Db.js +52 -0
  120. package/dist/db/Db.js.map +1 -1
  121. package/dist/db/Schema.d.ts +13 -0
  122. package/dist/db/Schema.d.ts.map +1 -1
  123. package/dist/db/Schema.js +16 -1
  124. package/dist/db/Schema.js.map +1 -1
  125. package/dist/db/tables/adminAuditLogs.d.ts +27 -0
  126. package/dist/db/tables/adminAuditLogs.d.ts.map +1 -0
  127. package/dist/db/tables/adminAuditLogs.js +19 -0
  128. package/dist/db/tables/adminAuditLogs.js.map +1 -0
  129. package/dist/db/tables/invitations.d.ts +1 -1
  130. package/dist/db/tables/invitations.d.ts.map +1 -1
  131. package/dist/db/tables/memberships.d.ts +32 -4
  132. package/dist/db/tables/memberships.d.ts.map +1 -1
  133. package/dist/db/tables/memberships.js +31 -0
  134. package/dist/db/tables/memberships.js.map +1 -1
  135. package/dist/db/tables/organizations.d.ts +43 -1
  136. package/dist/db/tables/organizations.d.ts.map +1 -1
  137. package/dist/db/tables/organizations.js +103 -0
  138. package/dist/db/tables/organizations.js.map +1 -1
  139. package/dist/db/tables/projects.d.ts +25 -0
  140. package/dist/db/tables/projects.d.ts.map +1 -1
  141. package/dist/db/tables/projects.js +22 -0
  142. package/dist/db/tables/projects.js.map +1 -1
  143. package/dist/db/tables/webhookDeliveries.d.ts +26 -0
  144. package/dist/db/tables/webhookDeliveries.d.ts.map +1 -1
  145. package/dist/db/tables/webhookDeliveries.js +37 -0
  146. package/dist/db/tables/webhookDeliveries.js.map +1 -1
  147. package/dist/internal/Auth.d.ts +46 -40
  148. package/dist/internal/Auth.d.ts.map +1 -1
  149. package/dist/internal/Auth.js +118 -69
  150. package/dist/internal/Auth.js.map +1 -1
  151. package/dist/internal/Cursor.d.ts +1 -1
  152. package/dist/internal/Cursor.d.ts.map +1 -1
  153. package/dist/internal/Cursor.js +4 -0
  154. package/dist/internal/Cursor.js.map +1 -1
  155. package/dist/internal/EdgeCache.d.ts.map +1 -1
  156. package/dist/internal/EdgeCache.js +2 -0
  157. package/dist/internal/EdgeCache.js.map +1 -1
  158. package/dist/internal/Errors.d.ts +6 -0
  159. package/dist/internal/Errors.d.ts.map +1 -0
  160. package/dist/internal/Errors.js +16 -0
  161. package/dist/internal/Errors.js.map +1 -0
  162. package/dist/internal/FundingRoutes.d.ts +15 -43
  163. package/dist/internal/FundingRoutes.d.ts.map +1 -1
  164. package/dist/internal/FundingRoutes.js +8 -17
  165. package/dist/internal/FundingRoutes.js.map +1 -1
  166. package/dist/internal/Log.d.ts +16 -1
  167. package/dist/internal/Log.d.ts.map +1 -1
  168. package/dist/internal/Log.js +5 -3
  169. package/dist/internal/Log.js.map +1 -1
  170. package/dist/internal/Store.d.ts +61 -16
  171. package/dist/internal/Store.d.ts.map +1 -1
  172. package/dist/internal/Store.js +72 -17
  173. package/dist/internal/Store.js.map +1 -1
  174. package/dist/internal/Viem.d.ts +1 -1
  175. package/dist/internal/index.d.ts +27 -0
  176. package/dist/internal/index.d.ts.map +1 -0
  177. package/dist/internal/index.js +27 -0
  178. package/dist/internal/index.js.map +1 -0
  179. package/package.json +8 -3
  180. package/src/ApiKey.ts +2 -3
  181. package/src/ApiKeys.ts +95 -17
  182. package/src/App.test.ts +104 -6
  183. package/src/App.ts +29 -9
  184. package/src/Assets.ts +8 -0
  185. package/src/Auth.test-d.ts +36 -0
  186. package/src/Auth.ts +20 -1
  187. package/src/Client.test-d.ts +17 -17
  188. package/src/Scope.test.ts +34 -0
  189. package/src/Scope.ts +53 -17
  190. package/src/admin/App.test.ts +33 -3
  191. package/src/admin/App.ts +49 -14
  192. package/src/admin/apps/api-keys.ts +10 -3
  193. package/src/admin/apps/organizations.test.ts +455 -0
  194. package/src/admin/apps/organizations.ts +953 -0
  195. package/src/admin/apps/scopes.test.ts +10 -0
  196. package/src/admin/ui/plugin.ts +1 -1
  197. package/src/admin/ui/src/components/MobileProfile.tsx +2 -0
  198. package/src/admin/ui/src/components/ThemeToggle.tsx +20 -0
  199. package/src/admin/ui/src/lib/api-keys.ts +36 -19
  200. package/src/admin/ui/src/lib/config.ts +2 -3
  201. package/src/admin/ui/src/lib/organizations.test.ts +89 -0
  202. package/src/admin/ui/src/lib/organizations.ts +262 -0
  203. package/src/admin/ui/src/lib/query-client.ts +3 -1
  204. package/src/admin/ui/src/lib/theme.ts +57 -0
  205. package/src/admin/ui/src/routeTree.gen.ts +47 -4
  206. package/src/admin/ui/src/routes/__root.tsx +18 -3
  207. package/src/admin/ui/src/routes/api-keys.tsx +52 -7
  208. package/src/admin/ui/src/routes/index.tsx +1 -1
  209. package/src/admin/ui/src/routes/organizations.tsx +158 -0
  210. package/src/admin/ui/src/routes/organizations_.$orgId.tsx +710 -0
  211. package/src/admin/ui/src/start.ts +6 -0
  212. package/src/admin/ui.generated.ts +63 -13
  213. package/src/analytics/Analytics.ts +25 -0
  214. package/src/analytics/tables/requestEvents.ts +33 -0
  215. package/src/apps/composition.test.ts +82 -2
  216. package/src/apps/data/FundingRouteProviders.test.ts +37 -93
  217. package/src/apps/data/FundingRouteProviders.ts +14 -161
  218. package/src/apps/data/routes/funding.test.ts +7 -42
  219. package/src/apps/data/routes/funding.ts +8 -8
  220. package/src/apps/data/routes/indexer.test.ts +2 -2
  221. package/src/apps/data/routes/webhooks.test.ts +2 -2
  222. package/src/apps/management/App.ts +2 -2
  223. package/src/apps/management/Billing.ts +10 -2
  224. package/src/apps/management/routes/api-keys.test.ts +137 -1
  225. package/src/apps/management/routes/api-keys.ts +63 -38
  226. package/src/apps/management/routes/billing.ts +14 -14
  227. package/src/apps/management/routes/invitations.test.ts +102 -1
  228. package/src/apps/management/routes/invitations.ts +13 -15
  229. package/src/apps/management/routes/members.ts +10 -10
  230. package/src/apps/management/routes/orgs.test.ts +78 -16
  231. package/src/apps/management/routes/orgs.ts +21 -20
  232. package/src/apps/management/routes/projects.ts +12 -13
  233. package/src/apps/management/routes/scopes.test.ts +16 -0
  234. package/src/apps/management/routes/scopes.ts +6 -7
  235. package/src/apps/management/routes/usage.ts +9 -14
  236. package/src/apps/mcp.test.ts +72 -7
  237. package/src/apps/mcp.ts +46 -4
  238. package/src/apps/relay/App.test.ts +23 -18
  239. package/src/apps/relay/App.ts +35 -14
  240. package/src/cloudflare.ts +5 -0
  241. package/src/db/Db.ts +59 -3
  242. package/src/db/Schema.ts +20 -1
  243. package/src/db/tables/adminAuditLogs.ts +47 -0
  244. package/src/db/tables/invitations.ts +1 -1
  245. package/src/db/tables/memberships.ts +59 -3
  246. package/src/db/tables/organizations.ts +148 -1
  247. package/src/db/tables/projects.ts +47 -0
  248. package/src/db/tables/webhookDeliveries.ts +83 -0
  249. package/src/internal/Auth.test.ts +571 -23
  250. package/src/internal/Auth.ts +166 -94
  251. package/src/internal/Cursor.test.ts +10 -0
  252. package/src/internal/Cursor.ts +5 -1
  253. package/src/internal/EdgeCache.test.ts +11 -0
  254. package/src/internal/EdgeCache.ts +2 -0
  255. package/src/internal/FundingRoutes.test.ts +8 -8
  256. package/src/internal/FundingRoutes.ts +25 -29
  257. package/src/internal/Log.test.ts +41 -3
  258. package/src/internal/Log.ts +23 -4
  259. package/src/internal/Store.test-d.ts +15 -0
  260. package/src/internal/Store.test.ts +153 -2
  261. package/src/internal/Store.ts +157 -41
  262. package/src/internal/index.test.ts +34 -0
  263. package/src/internal/index.ts +26 -0
package/src/admin/App.ts CHANGED
@@ -4,11 +4,14 @@ import { requestId, type RequestIdVariables } from 'hono/request-id'
4
4
 
5
5
  import * as ApiKey from '../ApiKey.js'
6
6
  import type * as Assets from '../Assets.js'
7
- import type * as Db from '../db/Db.js'
7
+ import type * as Analytics from '../analytics/Analytics.js'
8
+ import * as Db from '../db/Db.js'
9
+ import * as AdminAuditLogs from '../db/tables/adminAuditLogs.js'
8
10
  import type * as Store from '../internal/Store.js'
9
11
  import * as Response from '../internal/Response.js'
10
12
 
11
13
  import { apiKeys } from './apps/api-keys.js'
14
+ import { organizations } from './apps/organizations.js'
12
15
  import { scopes } from './apps/scopes.js'
13
16
  import { verifiedTokens } from './apps/verified-tokens.js'
14
17
  import * as Ui from './ui.generated.js'
@@ -22,6 +25,8 @@ export type Identity = {
22
25
  /** Hono environment for the admin app. */
23
26
  export type Environment = {
24
27
  Variables: RequestIdVariables & {
28
+ /** ClickHouse analytics source used by operational usage reads. */
29
+ analytics: Analytics.Source
25
30
  /** Static-asset writer (e.g. the R2 icon bucket), when configured. */
26
31
  assets: create.Assets | undefined
27
32
  /** Authoritative Postgres store, or a per-request factory. Resolve at the leaf with `Db.get`. */
@@ -54,6 +59,7 @@ export function create(options: create.Options) {
54
59
  app.use('*', async (c, next) => {
55
60
  // Admin data is per-principal and mutating: never cache.
56
61
  c.header('Cache-Control', 'no-store')
62
+ c.set('analytics', options.analytics)
57
63
  c.set('assets', options.assets)
58
64
  c.set('db', options.db)
59
65
  c.set('kv', options.kv)
@@ -73,17 +79,18 @@ export function create(options: create.Options) {
73
79
  }
74
80
  }
75
81
 
76
- // Renders the public SPA shell: injects a `<base href>` of the mount root (so
77
- // relative asset/API URLs resolve) and the auth config (read synchronously,
78
- // `identity` is `null` when logged out). `<` is escaped to keep `<script>`.
82
+ // Renders the public SPA shell: stamps its hydration-safe base and auth
83
+ // placeholders with request values. `<` is escaped to keep the config script.
79
84
  const serveShell = (c: Context<Environment>) => {
80
85
  const mount = c.req.routePath.replace(/\*$/, '').replace(/\/$/, '')
81
86
  const config = JSON.stringify(authConfig(c.get('identity'))).replaceAll('<', '\\u003c')
82
87
  return c.html(
83
- Ui.shell.replace(
84
- '<head>',
85
- `<head><base href="${mount}/"/><script>window.__TEMPO_API_CONFIG__=${config}</script>`,
86
- ),
88
+ Ui.shell
89
+ .replace(
90
+ '<base data-tempo-base="true" href="/"/>',
91
+ `<base data-tempo-base="true" href="${mount}/"/>`,
92
+ )
93
+ .replace('window.__TEMPO_API_CONFIG__=undefined', `window.__TEMPO_API_CONFIG__=${config}`),
87
94
  )
88
95
  }
89
96
 
@@ -106,20 +113,46 @@ export function create(options: create.Options) {
106
113
  })
107
114
  return next()
108
115
  }
109
- for (const path of [
116
+ const protectedPaths = [
110
117
  '/config.json',
111
118
  '/api-keys',
112
119
  '/api-keys/*',
120
+ '/organizations',
121
+ '/organizations/*',
113
122
  '/scopes',
114
123
  '/scopes/*',
115
124
  '/verified-tokens',
116
125
  '/verified-tokens/*',
117
- ])
118
- app.use(path, requireAuth)
126
+ ] as const
127
+ for (const path of protectedPaths) app.use(path, requireAuth)
128
+
129
+ for (const path of protectedPaths)
130
+ app.use(path, async (c, next) => {
131
+ const identity = c.get('identity')
132
+ if (!identity) return
133
+ const search = new URL(c.req.url).searchParams
134
+ const record = (status: number) =>
135
+ AdminAuditLogs.insert(Db.get(c.get('db')), {
136
+ actor: identity.email,
137
+ method: c.req.method,
138
+ path: c.req.path,
139
+ query: search.size ? search.toString() : undefined,
140
+ requestId: c.get('requestId'),
141
+ status,
142
+ })
143
+ try {
144
+ await next()
145
+ } catch (cause) {
146
+ await record(500)
147
+ throw cause
148
+ }
149
+ await record(c.res.status)
150
+ })
119
151
 
120
152
  const routed = app
121
153
  .get('/config.json', (c) => c.json(authConfig(c.get('identity')), 200))
122
154
  .route('/api-keys', apiKeys())
155
+ .route('/organizations', organizations())
123
156
  .route('/scopes', scopes())
124
157
  .route('/verified-tokens', verifiedTokens())
125
158
 
@@ -172,15 +205,17 @@ export declare namespace create {
172
205
 
173
206
  /** Options for {@link create}. */
174
207
  type Options = {
208
+ /** ClickHouse analytics source used by operational usage reads. */
209
+ analytics: Analytics.Source
175
210
  /**
176
211
  * Static-asset writer for logo uploads (shared with the main API's `assets`
177
212
  * loader) to enable `PUT /verified-tokens/:address/logo`; omit for `404`.
178
213
  */
179
214
  assets?: Assets | undefined
180
215
  /**
181
- * Authoritative Postgres store shared with the main API (verified
182
- * tokens). Pass a long-lived `Db.postgres(...)` singleton on Node, or a
183
- * factory (`() => Db.postgres(...)`) on Workers.
216
+ * Authoritative Postgres store shared with the main API. Pass a long-lived
217
+ * `Db.postgres(...)` singleton on Node, or a factory
218
+ * (`() => Db.postgres(...)`) on Workers.
184
219
  */
185
220
  db: Db.Source
186
221
  /**
@@ -5,6 +5,7 @@ import * as ApiKeys from '../../ApiKeys.js'
5
5
  import * as OpenApi from '../../internal/OpenApi.js'
6
6
  import * as Response from '../../internal/Response.js'
7
7
  import * as Schema from '../../internal/Schema.js'
8
+ import * as Scope from '../../Scope.js'
8
9
  import type * as App from '../App.js'
9
10
 
10
11
  /** Zod schemas owned by the admin API-keys handler. */
@@ -16,7 +17,11 @@ export namespace schema {
16
17
  * server sets from the verified identity so it can't be spoofed).
17
18
  */
18
19
  export const Body = Schema.describe(
19
- z.omit(ApiKeys.schema.MintInput, { createdBy: true }),
20
+ z.extend(z.omit(ApiKeys.schema.MintInput, { createdBy: true }), {
21
+ scopes: z
22
+ .readonly(z.array(z.enum([...Scope.catalog.map((entry) => entry.scope), Scope.wildcard])))
23
+ .check(z.describe("Granted scopes. '*' grants all scopes.")),
24
+ }),
20
25
  'Fields accepted when minting an API key.',
21
26
  )
22
27
 
@@ -59,7 +64,7 @@ export namespace schema {
59
64
  export const Body = Schema.describe(
60
65
  z.object({
61
66
  orgId: z.optional(z.string()).check(z.describe('New owning organization id (`org_…`).')),
62
- projectId: z.optional(z.string()).check(z.describe('New owning project id (`prj_…`).')),
67
+ projectId: z.optional(z.string()).check(z.describe('New attributed project id (`prj_…`).')),
63
68
  }),
64
69
  'Attribution to apply to an API key.',
65
70
  )
@@ -174,7 +179,9 @@ export function apiKeys() {
174
179
  }),
175
180
  async (c) => {
176
181
  const { orgId } = c.req.valid('query')
177
- const data = await ApiKeys.list(c.get('kv').store, orgId ? { orgId } : {})
182
+ const data = orgId
183
+ ? await ApiKeys.listByOrg(c.get('kv').store, orgId)
184
+ : await ApiKeys.list(c.get('kv').store)
178
185
  return c.json(Response.validated(schema.listApiKeys.Response, { data }))
179
186
  },
180
187
  )
@@ -0,0 +1,455 @@
1
+ import type * as Analytics from '../../analytics/Analytics.js'
2
+ import * as ApiKeys from '../../ApiKeys.js'
3
+ import * as BillingSettings from '../../db/tables/billingSettings.js'
4
+ import * as Memberships from '../../db/tables/memberships.js'
5
+ import * as Organizations from '../../db/tables/organizations.js'
6
+ import * as Projects from '../../db/tables/projects.js'
7
+ import * as SponsoredTransactions from '../../db/tables/sponsoredTransactions.js'
8
+ import * as StripeCustomers from '../../db/tables/stripeCustomers.js'
9
+ import * as Users from '../../db/tables/users.js'
10
+ import * as WebhookDeliveries from '../../db/tables/webhookDeliveries.js'
11
+ import * as WebhookSubscriptions from '../../db/tables/webhookSubscriptions.js'
12
+ import * as Viem from '../../internal/Viem.js'
13
+
14
+ import * as TestAdmin from '../../../test/Admin.js'
15
+ import * as TestApp from '../../../test/App.js'
16
+ import * as AdminOrganizations from './organizations.js'
17
+
18
+ describe('GET /organizations', () => {
19
+ test('lists summaries newest first with member and project counts', async () => {
20
+ const { client, db } = TestAdmin.setup()
21
+ const user = await Users.upsertByAddress(db, {
22
+ address: `0x${'11'.repeat(20)}`,
23
+ })
24
+ await Users.setEmail(db, user.id, 'owner@acme.example')
25
+ const organization = await Organizations.createOwned(db, { name: 'Acme', userId: user.id })
26
+ await Projects.create(db, { name: 'Checkout', orgId: organization.id })
27
+ await Projects.create(db, { name: 'Treasury', orgId: organization.id })
28
+
29
+ const response = await client.organizations.$get({ query: {} })
30
+ const body = await TestApp.json(response, AdminOrganizations.schema.listOrganizations.Response)
31
+
32
+ expect(response.status).toBe(200)
33
+ expect(body.nextCursor).toBeNull()
34
+ expect(body.data).toHaveLength(1)
35
+ expect(body.data[0]).toMatchObject({
36
+ id: organization.id,
37
+ memberCount: 1,
38
+ name: 'Acme',
39
+ projectCount: 2,
40
+ userId: user.id,
41
+ })
42
+ })
43
+
44
+ test('searches organization and member identity fields case-insensitively', async () => {
45
+ const { client, db } = TestAdmin.setup()
46
+ const user = await Users.upsertByAddress(db, {
47
+ address: `0x${'22'.repeat(20)}`,
48
+ })
49
+ await Users.setEmail(db, user.id, 'Owner@Example.com')
50
+ const organization = await Organizations.createOwned(db, {
51
+ name: 'Merchant Operations',
52
+ userId: user.id,
53
+ })
54
+ await Organizations.create(db, { id: 'org_unrelated', name: 'Unrelated' })
55
+
56
+ for (const query of ['merchant', organization.id, user.id, 'owner@']) {
57
+ const response = await client.organizations.$get({ query: { query } })
58
+ const body = await TestApp.json(
59
+ response,
60
+ AdminOrganizations.schema.listOrganizations.Response,
61
+ )
62
+ expect(body.data.map(({ id }) => id)).toEqual([organization.id])
63
+ }
64
+ })
65
+
66
+ test('treats SQL wildcard characters as literal search text', async () => {
67
+ const { client, db } = TestAdmin.setup()
68
+ const organization = await Organizations.create(db, { name: 'A_B Merchant' })
69
+ await Organizations.create(db, { name: 'AcB Merchant' })
70
+
71
+ const response = await client.organizations.$get({ query: { query: 'a_b' } })
72
+ const body = await TestApp.json(response, AdminOrganizations.schema.listOrganizations.Response)
73
+
74
+ expect(body.data.map(({ id }) => id)).toEqual([organization.id])
75
+ })
76
+
77
+ test('orders distinct creation times newest first', async () => {
78
+ const { client, db } = TestAdmin.setup()
79
+ vi.useFakeTimers()
80
+ try {
81
+ vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
82
+ const older = await Organizations.create(db, { name: 'Older' })
83
+ vi.setSystemTime(new Date('2026-01-02T00:00:00.000Z'))
84
+ const newer = await Organizations.create(db, { name: 'Newer' })
85
+
86
+ const response = await client.organizations.$get({ query: {} })
87
+ const body = await TestApp.json(
88
+ response,
89
+ AdminOrganizations.schema.listOrganizations.Response,
90
+ )
91
+ expect(body.data.map(({ id }) => id)).toEqual([newer.id, older.id])
92
+ } finally {
93
+ vi.useRealTimers()
94
+ }
95
+ })
96
+
97
+ test('rejects broad queries shorter than three characters', async () => {
98
+ const { client } = TestAdmin.setup()
99
+ const response = await client.organizations.$get({ query: { query: 'a' } })
100
+ expect(response.status).toBe(400)
101
+ })
102
+
103
+ test('walks a stable cursor without duplicate organizations', async () => {
104
+ const { client, db } = TestAdmin.setup()
105
+ for (const id of ['org_f', 'org_e', 'org_d', 'org_c', 'org_b', 'org_a'])
106
+ await Organizations.create(db, { id, name: id })
107
+
108
+ const firstResponse = await client.organizations.$get({ query: { limit: '5' } })
109
+ const first = await TestApp.json(
110
+ firstResponse,
111
+ AdminOrganizations.schema.listOrganizations.Response,
112
+ )
113
+ expect(first.data).toHaveLength(5)
114
+ expect(first.nextCursor).not.toBeNull()
115
+
116
+ const secondResponse = await client.organizations.$get({
117
+ query: { cursor: first.nextCursor!, limit: '5' },
118
+ })
119
+ const second = await TestApp.json(
120
+ secondResponse,
121
+ AdminOrganizations.schema.listOrganizations.Response,
122
+ )
123
+ expect(second.data).toHaveLength(1)
124
+ expect(second.nextCursor).toBeNull()
125
+ expect(new Set([...first.data, ...second.data].map(({ id }) => id)).size).toBe(6)
126
+ })
127
+ })
128
+
129
+ describe('GET /organizations/:orgId', () => {
130
+ test('returns counts and billing state split by environment', async () => {
131
+ const { client, db } = TestAdmin.setup()
132
+ const user = await Users.upsertByAddress(db, {
133
+ address: `0x${'33'.repeat(20)}`,
134
+ })
135
+ await Users.setEmail(db, user.id, 'owner@tempo.xyz')
136
+ const organization = await Organizations.createOwned(db, { name: 'Tempo', userId: user.id })
137
+ await Projects.create(db, { name: 'API', orgId: organization.id })
138
+ await BillingSettings.upsert(db, {
139
+ environment: 'production',
140
+ orgId: organization.id,
141
+ spendLimit: '250',
142
+ txFeeLimit: '0.50',
143
+ })
144
+ await BillingSettings.upsert(db, {
145
+ environment: 'sandbox',
146
+ orgId: organization.id,
147
+ spendLimit: '1000',
148
+ })
149
+ await StripeCustomers.create(db, {
150
+ environment: 'production',
151
+ orgId: organization.id,
152
+ stripeCustomerId: 'cus_admin_lookup',
153
+ })
154
+ await StripeCustomers.setStatus(db, organization.id, 'active', 'production')
155
+
156
+ const response = await client.organizations[':orgId'].$get({
157
+ param: { orgId: organization.id },
158
+ })
159
+ const body = await TestApp.json(response, AdminOrganizations.schema.getOrganization.Response)
160
+
161
+ expect(response.status).toBe(200)
162
+ expect(body.memberCount).toBe(1)
163
+ expect(body.projectCount).toBe(1)
164
+ expect(body.billing.production).toMatchObject({
165
+ settings: { spendLimit: '250', txFeeLimit: '0.50' },
166
+ source: { status: 'active', stripeCustomerId: 'cus_admin_lookup' },
167
+ })
168
+ expect(body.billing.sandbox).toMatchObject({
169
+ settings: { spendLimit: '1000', txFeeLimit: null },
170
+ source: null,
171
+ })
172
+ })
173
+
174
+ test('returns empty counts and billing for a memberless organization', async () => {
175
+ const { client, db } = TestAdmin.setup()
176
+ const organization = await Organizations.create(db, { name: 'Support-created' })
177
+
178
+ const response = await client.organizations[':orgId'].$get({
179
+ param: { orgId: organization.id },
180
+ })
181
+ const body = await TestApp.json(response, AdminOrganizations.schema.getOrganization.Response)
182
+
183
+ expect(body).toMatchObject({
184
+ billing: {
185
+ production: { settings: null, source: null },
186
+ sandbox: { settings: null, source: null },
187
+ },
188
+ memberCount: 0,
189
+ projectCount: 0,
190
+ })
191
+ })
192
+
193
+ test('returns 404 for an unknown organization', async () => {
194
+ const { client } = TestAdmin.setup()
195
+ const response = await client.organizations[':orgId'].$get({
196
+ param: { orgId: 'org_missing' },
197
+ })
198
+ const body = (await response.json()) as { error: { code: string } }
199
+
200
+ expect(response.status).toBe(404)
201
+ expect(body.error.code).toBe('not_found')
202
+ })
203
+ })
204
+
205
+ describe('GET /organizations/:orgId/members', () => {
206
+ test('returns a bounded cursor page without duplicates', async () => {
207
+ const { client, db } = TestAdmin.setup()
208
+ const organization = await Organizations.create(db, { name: 'Members' })
209
+ for (let index = 0; index < 6; index++) {
210
+ const user = await Users.upsertByAddress(db, {
211
+ address: `0x${String(index + 1).padStart(40, '0')}`,
212
+ })
213
+ await Users.setEmail(db, user.id, `member-${index}@example.com`)
214
+ await Memberships.create(db, { orgId: organization.id, role: 'member', userId: user.id })
215
+ }
216
+
217
+ const firstResponse = await client.organizations[':orgId'].members.$get({
218
+ param: { orgId: organization.id },
219
+ query: { limit: '5' },
220
+ })
221
+ const first = await TestApp.json(
222
+ firstResponse,
223
+ AdminOrganizations.schema.listOrganizationMembers.Response,
224
+ )
225
+ const secondResponse = await client.organizations[':orgId'].members.$get({
226
+ param: { orgId: organization.id },
227
+ query: { cursor: first.nextCursor!, limit: '5' },
228
+ })
229
+ const second = await TestApp.json(
230
+ secondResponse,
231
+ AdminOrganizations.schema.listOrganizationMembers.Response,
232
+ )
233
+
234
+ expect(first.data).toHaveLength(5)
235
+ expect(second.data).toHaveLength(1)
236
+ expect(new Set([...first.data, ...second.data].map(({ userId }) => userId)).size).toBe(6)
237
+ })
238
+ })
239
+
240
+ describe('GET /organizations/:orgId/projects', () => {
241
+ test('returns a bounded cursor page without duplicates', async () => {
242
+ const { client, db } = TestAdmin.setup()
243
+ const organization = await Organizations.create(db, { name: 'Projects' })
244
+ for (let index = 0; index < 6; index++)
245
+ await Projects.create(db, { name: `Project ${index}`, orgId: organization.id })
246
+
247
+ const firstResponse = await client.organizations[':orgId'].projects.$get({
248
+ param: { orgId: organization.id },
249
+ query: { limit: '5' },
250
+ })
251
+ const first = await TestApp.json(
252
+ firstResponse,
253
+ AdminOrganizations.schema.listOrganizationProjects.Response,
254
+ )
255
+ const secondResponse = await client.organizations[':orgId'].projects.$get({
256
+ param: { orgId: organization.id },
257
+ query: { cursor: first.nextCursor!, limit: '5' },
258
+ })
259
+ const second = await TestApp.json(
260
+ secondResponse,
261
+ AdminOrganizations.schema.listOrganizationProjects.Response,
262
+ )
263
+
264
+ expect(first.data).toHaveLength(5)
265
+ expect(second.data).toHaveLength(1)
266
+ expect(new Set([...first.data, ...second.data].map(({ id }) => id)).size).toBe(6)
267
+ })
268
+ })
269
+
270
+ describe('GET /organizations/:orgId/api-keys', () => {
271
+ test('returns active key environment, scopes, and creator without tokens', async () => {
272
+ const { client, db, store } = TestAdmin.setup()
273
+ const organization = await Organizations.create(db, { name: 'Keys' })
274
+ await ApiKeys.mint(store, {
275
+ createdBy: 'ops@tempo.xyz',
276
+ environment: 'sandbox',
277
+ orgId: organization.id,
278
+ scopes: ['data:read'],
279
+ })
280
+
281
+ const response = await client.organizations[':orgId']['api-keys'].$get({
282
+ param: { orgId: organization.id },
283
+ })
284
+ const body = await TestApp.json(
285
+ response,
286
+ AdminOrganizations.schema.listOrganizationApiKeys.Response,
287
+ )
288
+
289
+ expect(body.data[0]).toMatchObject({
290
+ createdBy: 'ops@tempo.xyz',
291
+ environment: 'sandbox',
292
+ scopes: ['data:read'],
293
+ })
294
+ expect(body.data[0]).not.toHaveProperty('status')
295
+ expect(body.data[0]).not.toHaveProperty('token')
296
+ })
297
+ })
298
+
299
+ describe('GET /organizations/:orgId/usage', () => {
300
+ test('returns recent totals and error breakdowns from analytics', async () => {
301
+ const { client, db } = TestAdmin.setup({ analytics: usageAnalytics })
302
+ const organization = await Organizations.create(db, { name: 'Usage' })
303
+ const response = await client.organizations[':orgId'].usage.$get({
304
+ param: { orgId: organization.id },
305
+ query: { period: '7d' },
306
+ })
307
+ const body = await TestApp.json(
308
+ response,
309
+ AdminOrganizations.schema.getOrganizationUsage.Response,
310
+ )
311
+
312
+ expect(body).toMatchObject({
313
+ byError: [{ code: 'upstream_error', requests: 2 }],
314
+ byRoute: [{ averageDurationMs: 12.5, errors: 2, requests: 20, route: '/v1/tokens' }],
315
+ byStatus: [{ requests: 2, status: 500 }],
316
+ period: '7d',
317
+ totals: { averageDurationMs: 10.5, errors: 2, requests: 25 },
318
+ })
319
+ })
320
+ })
321
+
322
+ describe('GET /organizations/:orgId/sponsorship', () => {
323
+ test('returns current-period committed spend split by environment', async () => {
324
+ const { client, db } = TestAdmin.setup()
325
+ const organization = await Organizations.create(db, { name: 'Sponsorship' })
326
+ await SponsoredTransactions.upsert(db, {
327
+ apiKeyId: 'key_1',
328
+ billable: true,
329
+ chainId: Viem.chainId.mainnet,
330
+ environment: 'production',
331
+ feeMax: '1250000',
332
+ orgId: organization.id,
333
+ projectId: 'prj_1',
334
+ signPayload: `0x${'11'.repeat(32)}`,
335
+ transaction: `0x${'22'.repeat(32)}`,
336
+ })
337
+
338
+ const response = await client.organizations[':orgId'].sponsorship.$get({
339
+ param: { orgId: organization.id },
340
+ })
341
+ const body = await TestApp.json(
342
+ response,
343
+ AdminOrganizations.schema.getOrganizationSponsorship.Response,
344
+ )
345
+
346
+ expect(body.production).toMatchObject({
347
+ committedSpend: '1.25',
348
+ currency: 'usd',
349
+ period: 'month',
350
+ })
351
+ expect(body.sandbox).toMatchObject({
352
+ committedSpend: '0',
353
+ currency: 'usd',
354
+ period: 'month',
355
+ })
356
+ })
357
+ })
358
+
359
+ describe('GET /organizations/:orgId/webhooks', () => {
360
+ test('redacts destinations and returns recent delivery failures', async () => {
361
+ const { client, db } = TestAdmin.setup()
362
+ const organization = await Organizations.create(db, { name: 'Webhooks' })
363
+ const createdAt = new Date().toISOString()
364
+ await WebhookSubscriptions.insert(db, {
365
+ chainId: 4217,
366
+ createdAt,
367
+ destination: { type: 'url', url: 'https://hooks.example.com/private/path' },
368
+ eventType: 'token:transfer',
369
+ failureCount: 1,
370
+ filters: {},
371
+ id: 'wh_admin_visibility',
372
+ owner: { orgId: organization.id, type: 'api_key' },
373
+ secret: 'secret',
374
+ status: 'active',
375
+ updatedAt: createdAt,
376
+ })
377
+ for (let index = 0; index < 26; index++)
378
+ await WebhookDeliveries.insert(
379
+ db,
380
+ {
381
+ attempt: 1,
382
+ createdAt,
383
+ envelope: {
384
+ chainId: 4217,
385
+ createdAt,
386
+ data: {},
387
+ id: `evt_admin_visibility_${index}`,
388
+ subscriptionId: 'wh_admin_visibility',
389
+ type: 'token:transfer',
390
+ },
391
+ error: 'HTTP 500',
392
+ eventId: `evt_admin_visibility_${index}`,
393
+ id: `whd_admin_visibility_${index}`,
394
+ requestUrl: 'https://hooks.example.com/private/path',
395
+ responseStatus: 500,
396
+ status: 'failed',
397
+ subscriptionId: 'wh_admin_visibility',
398
+ },
399
+ new Date(Date.now() + 60_000).toISOString(),
400
+ )
401
+
402
+ const oldCreatedAt = new Date(Date.now() - 8 * 24 * 60 * 60 * 1_000).toISOString()
403
+ await WebhookDeliveries.insert(
404
+ db,
405
+ {
406
+ attempt: 1,
407
+ createdAt: oldCreatedAt,
408
+ envelope: {
409
+ chainId: 4217,
410
+ createdAt: oldCreatedAt,
411
+ data: {},
412
+ id: 'evt_admin_visibility_old',
413
+ subscriptionId: 'wh_admin_visibility',
414
+ type: 'token:transfer',
415
+ },
416
+ error: 'HTTP 500',
417
+ eventId: 'evt_admin_visibility_old',
418
+ id: 'whd_admin_visibility_old',
419
+ requestUrl: 'https://hooks.example.com/private/path',
420
+ responseStatus: 500,
421
+ status: 'failed',
422
+ subscriptionId: 'wh_admin_visibility',
423
+ },
424
+ new Date(Date.now() + 60_000).toISOString(),
425
+ )
426
+
427
+ const response = await client.organizations[':orgId'].webhooks.$get({
428
+ param: { orgId: organization.id },
429
+ })
430
+ const body = await TestApp.json(
431
+ response,
432
+ AdminOrganizations.schema.getOrganizationWebhooks.Response,
433
+ )
434
+
435
+ expect(body.subscriptions[0]?.destination).toBe('https://hooks.example.com/…')
436
+ expect(body.failureCount).toBe(26)
437
+ expect(body.failures).toHaveLength(25)
438
+ expect(body.failures[0]).toMatchObject({ error: 'HTTP 500', responseStatus: 500 })
439
+ expect(body.failuresTruncated).toBe(true)
440
+ })
441
+ })
442
+
443
+ const usageAnalytics: Analytics.Analytics = {
444
+ async insert() {},
445
+ async migrate() {},
446
+ async query(sql) {
447
+ if (sql.includes('GROUP BY error_code'))
448
+ return [{ code: 'upstream_error', requests: 2 }] as never[]
449
+ if (sql.includes('GROUP BY route'))
450
+ return [{ averageDurationMs: 12.5, errors: 2, requests: 20, route: '/v1/tokens' }] as never[]
451
+ if (sql.includes('GROUP BY status')) return [{ requests: 2, status: 500 }] as never[]
452
+ if (sql.includes('GROUP BY key_id') || sql.includes('GROUP BY toStartOf')) return []
453
+ return [{ averageDurationMs: 10.5, errors: 2, requests: 25 }] as never[]
454
+ },
455
+ }