tapimo 0.5.2 → 0.6.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 (299) hide show
  1. package/dist/App.d.ts +13987 -5755
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js.map +1 -1
  4. package/dist/admin/App.d.ts +910 -412
  5. package/dist/admin/App.d.ts.map +1 -1
  6. package/dist/admin/App.js +4 -0
  7. package/dist/admin/App.js.map +1 -1
  8. package/dist/admin/apps/early-access.d.ts +148 -0
  9. package/dist/admin/apps/early-access.d.ts.map +1 -0
  10. package/dist/admin/apps/early-access.js +133 -0
  11. package/dist/admin/apps/early-access.js.map +1 -0
  12. package/dist/admin/apps/verified-tokens.d.ts +643 -118
  13. package/dist/admin/apps/verified-tokens.d.ts.map +1 -1
  14. package/dist/admin/apps/verified-tokens.js +214 -1
  15. package/dist/admin/apps/verified-tokens.js.map +1 -1
  16. package/dist/admin/ui.generated.d.ts +1 -1
  17. package/dist/admin/ui.generated.d.ts.map +1 -1
  18. package/dist/admin/ui.generated.js +45 -33
  19. package/dist/admin/ui.generated.js.map +1 -1
  20. package/dist/apps/data/App.d.ts +3733 -2428
  21. package/dist/apps/data/App.d.ts.map +1 -1
  22. package/dist/apps/data/App.js +11 -6
  23. package/dist/apps/data/App.js.map +1 -1
  24. package/dist/apps/data/FundingQuoteProviders.d.ts +34 -1
  25. package/dist/apps/data/FundingQuoteProviders.d.ts.map +1 -1
  26. package/dist/apps/data/FundingQuoteProviders.js +193 -2
  27. package/dist/apps/data/FundingQuoteProviders.js.map +1 -1
  28. package/dist/apps/data/FxOracle.d.ts +71 -0
  29. package/dist/apps/data/FxOracle.d.ts.map +1 -0
  30. package/dist/apps/data/FxOracle.js +97 -0
  31. package/dist/apps/data/FxOracle.js.map +1 -0
  32. package/dist/apps/data/index.d.ts +1 -0
  33. package/dist/apps/data/index.d.ts.map +1 -1
  34. package/dist/apps/data/index.js +1 -0
  35. package/dist/apps/data/index.js.map +1 -1
  36. package/dist/apps/data/routes/activities.d.ts +3575 -1358
  37. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  38. package/dist/apps/data/routes/activities.js +230 -88
  39. package/dist/apps/data/routes/activities.js.map +1 -1
  40. package/dist/apps/data/routes/addresses.d.ts +963 -342
  41. package/dist/apps/data/routes/addresses.d.ts.map +1 -1
  42. package/dist/apps/data/routes/addresses.js +11 -9
  43. package/dist/apps/data/routes/addresses.js.map +1 -1
  44. package/dist/apps/data/routes/balances.d.ts +108 -57
  45. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  46. package/dist/apps/data/routes/balances.js +88 -45
  47. package/dist/apps/data/routes/balances.js.map +1 -1
  48. package/dist/apps/data/routes/coingecko.d.ts +169 -169
  49. package/dist/apps/data/routes/exchanges.d.ts +952 -201
  50. package/dist/apps/data/routes/exchanges.d.ts.map +1 -1
  51. package/dist/apps/data/routes/exchanges.js +518 -76
  52. package/dist/apps/data/routes/exchanges.js.map +1 -1
  53. package/dist/apps/data/routes/fee-amm.d.ts +24 -24
  54. package/dist/apps/data/routes/funding.d.ts +8 -2
  55. package/dist/apps/data/routes/funding.d.ts.map +1 -1
  56. package/dist/apps/data/routes/funding.js +32 -6
  57. package/dist/apps/data/routes/funding.js.map +1 -1
  58. package/dist/apps/data/routes/indexer.d.ts +5 -5
  59. package/dist/apps/data/routes/receipts.d.ts +392 -315
  60. package/dist/apps/data/routes/receipts.d.ts.map +1 -1
  61. package/dist/apps/data/routes/receipts.js +152 -77
  62. package/dist/apps/data/routes/receipts.js.map +1 -1
  63. package/dist/apps/data/routes/tokens.d.ts +166 -173
  64. package/dist/apps/data/routes/tokens.d.ts.map +1 -1
  65. package/dist/apps/data/routes/tokens.js +14 -12
  66. package/dist/apps/data/routes/tokens.js.map +1 -1
  67. package/dist/apps/data/routes/transactions.d.ts +277 -536
  68. package/dist/apps/data/routes/transactions.d.ts.map +1 -1
  69. package/dist/apps/data/routes/transactions.js +96 -77
  70. package/dist/apps/data/routes/transactions.js.map +1 -1
  71. package/dist/apps/data/routes/transfers.d.ts +82 -17
  72. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  73. package/dist/apps/data/routes/transfers.js +70 -13
  74. package/dist/apps/data/routes/transfers.js.map +1 -1
  75. package/dist/apps/data/routes/valuation.d.ts +307 -0
  76. package/dist/apps/data/routes/valuation.d.ts.map +1 -0
  77. package/dist/apps/data/routes/valuation.js +292 -0
  78. package/dist/apps/data/routes/valuation.js.map +1 -0
  79. package/dist/apps/data/routes/verified-tokens.d.ts.map +1 -1
  80. package/dist/apps/data/routes/verified-tokens.js +7 -5
  81. package/dist/apps/data/routes/verified-tokens.js.map +1 -1
  82. package/dist/apps/data/routes/webhooks.d.ts +459 -479
  83. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  84. package/dist/apps/data/routes/webhooks.js +124 -25
  85. package/dist/apps/data/routes/webhooks.js.map +1 -1
  86. package/dist/apps/index.d.ts +1 -1
  87. package/dist/apps/index.d.ts.map +1 -1
  88. package/dist/apps/index.js +1 -1
  89. package/dist/apps/index.js.map +1 -1
  90. package/dist/apps/management/App.d.ts +8606 -1681
  91. package/dist/apps/management/App.d.ts.map +1 -1
  92. package/dist/apps/management/App.js +12 -1
  93. package/dist/apps/management/App.js.map +1 -1
  94. package/dist/apps/management/routes/api-keys.d.ts +23 -0
  95. package/dist/apps/management/routes/api-keys.d.ts.map +1 -1
  96. package/dist/apps/management/routes/api-keys.js +5 -3
  97. package/dist/apps/management/routes/api-keys.js.map +1 -1
  98. package/dist/apps/management/routes/billing.d.ts +88 -48
  99. package/dist/apps/management/routes/billing.d.ts.map +1 -1
  100. package/dist/apps/management/routes/billing.js +10 -2
  101. package/dist/apps/management/routes/billing.js.map +1 -1
  102. package/dist/apps/management/routes/faucet.d.ts +218 -0
  103. package/dist/apps/management/routes/faucet.d.ts.map +1 -0
  104. package/dist/apps/management/routes/faucet.js +139 -0
  105. package/dist/apps/management/routes/faucet.js.map +1 -0
  106. package/dist/apps/management/routes/invitations.d.ts +121 -121
  107. package/dist/apps/management/routes/invitations.d.ts.map +1 -1
  108. package/dist/apps/management/routes/invitations.js +3 -0
  109. package/dist/apps/management/routes/invitations.js.map +1 -1
  110. package/dist/apps/management/routes/invite-links.d.ts +197 -168
  111. package/dist/apps/management/routes/invite-links.d.ts.map +1 -1
  112. package/dist/apps/management/routes/invite-links.js +26 -1
  113. package/dist/apps/management/routes/invite-links.js.map +1 -1
  114. package/dist/apps/management/routes/me.d.ts +2 -0
  115. package/dist/apps/management/routes/me.d.ts.map +1 -1
  116. package/dist/apps/management/routes/me.js +4 -0
  117. package/dist/apps/management/routes/me.js.map +1 -1
  118. package/dist/apps/management/routes/members.d.ts +49 -49
  119. package/dist/apps/management/routes/orgs.d.ts +91 -91
  120. package/dist/apps/management/routes/projects.d.ts +115 -115
  121. package/dist/apps/management/routes/usage.d.ts +35 -32
  122. package/dist/apps/management/routes/usage.d.ts.map +1 -1
  123. package/dist/apps/management/routes/usage.js +4 -0
  124. package/dist/apps/management/routes/usage.js.map +1 -1
  125. package/dist/apps/management/routes/verified-token-requests.d.ts +697 -0
  126. package/dist/apps/management/routes/verified-token-requests.d.ts.map +1 -0
  127. package/dist/apps/management/routes/verified-token-requests.js +271 -0
  128. package/dist/apps/management/routes/verified-token-requests.js.map +1 -0
  129. package/dist/apps/management/routes/webhooks.d.ts +6977 -0
  130. package/dist/apps/management/routes/webhooks.d.ts.map +1 -0
  131. package/dist/apps/management/routes/webhooks.js +573 -0
  132. package/dist/apps/management/routes/webhooks.js.map +1 -0
  133. package/dist/apps/mcp.d.ts +9 -2
  134. package/dist/apps/mcp.d.ts.map +1 -1
  135. package/dist/apps/mcp.js +9 -4
  136. package/dist/apps/mcp.js.map +1 -1
  137. package/dist/cli/Cli.d.ts.map +1 -1
  138. package/dist/cli/Cli.js +7 -2
  139. package/dist/cli/Cli.js.map +1 -1
  140. package/dist/db/Db.d.ts +4 -0
  141. package/dist/db/Db.d.ts.map +1 -1
  142. package/dist/db/Db.js +83 -0
  143. package/dist/db/Db.js.map +1 -1
  144. package/dist/db/Schema.d.ts +41 -0
  145. package/dist/db/Schema.d.ts.map +1 -1
  146. package/dist/db/Schema.js +45 -0
  147. package/dist/db/Schema.js.map +1 -1
  148. package/dist/db/tables/earlyAccess.d.ts +50 -0
  149. package/dist/db/tables/earlyAccess.d.ts.map +1 -0
  150. package/dist/db/tables/earlyAccess.js +63 -0
  151. package/dist/db/tables/earlyAccess.js.map +1 -0
  152. package/dist/db/tables/invitations.d.ts +12 -1
  153. package/dist/db/tables/invitations.d.ts.map +1 -1
  154. package/dist/db/tables/invitations.js +23 -0
  155. package/dist/db/tables/invitations.js.map +1 -1
  156. package/dist/db/tables/inviteLinks.d.ts +10 -2
  157. package/dist/db/tables/inviteLinks.d.ts.map +1 -1
  158. package/dist/db/tables/inviteLinks.js +10 -1
  159. package/dist/db/tables/inviteLinks.js.map +1 -1
  160. package/dist/db/tables/sponsoredTransactions.d.ts +2 -0
  161. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  162. package/dist/db/tables/sponsoredTransactions.js +2 -0
  163. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  164. package/dist/db/tables/verifiedTokenRequests.d.ts +94 -0
  165. package/dist/db/tables/verifiedTokenRequests.d.ts.map +1 -0
  166. package/dist/db/tables/verifiedTokenRequests.js +120 -0
  167. package/dist/db/tables/verifiedTokenRequests.js.map +1 -0
  168. package/dist/db/tables/webhookSubscriptions.d.ts +14 -0
  169. package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
  170. package/dist/db/tables/webhookSubscriptions.js +16 -0
  171. package/dist/db/tables/webhookSubscriptions.js.map +1 -1
  172. package/dist/internal/Auth.d.ts +43 -2
  173. package/dist/internal/Auth.d.ts.map +1 -1
  174. package/dist/internal/Auth.js +60 -45
  175. package/dist/internal/Auth.js.map +1 -1
  176. package/dist/internal/FundingQuotes.d.ts +69 -15
  177. package/dist/internal/FundingQuotes.d.ts.map +1 -1
  178. package/dist/internal/FundingQuotes.js +74 -25
  179. package/dist/internal/FundingQuotes.js.map +1 -1
  180. package/dist/internal/Log.d.ts +19 -1
  181. package/dist/internal/Log.d.ts.map +1 -1
  182. package/dist/internal/Log.js +2 -0
  183. package/dist/internal/Log.js.map +1 -1
  184. package/dist/internal/MetricSink.d.ts.map +1 -1
  185. package/dist/internal/MetricSink.js +9 -0
  186. package/dist/internal/MetricSink.js.map +1 -1
  187. package/dist/internal/OpenApi.d.ts +2 -2
  188. package/dist/internal/OpenApi.d.ts.map +1 -1
  189. package/dist/internal/OpenApi.js +3 -0
  190. package/dist/internal/OpenApi.js.map +1 -1
  191. package/dist/internal/Schema.d.ts +14 -5
  192. package/dist/internal/Schema.d.ts.map +1 -1
  193. package/dist/internal/Schema.js +23 -5
  194. package/dist/internal/Schema.js.map +1 -1
  195. package/dist/internal/Value.d.ts +18 -0
  196. package/dist/internal/Value.d.ts.map +1 -1
  197. package/dist/internal/Value.js +11 -1
  198. package/dist/internal/Value.js.map +1 -1
  199. package/dist/internal/VerifiedTokens.d.ts.map +1 -1
  200. package/dist/internal/VerifiedTokens.js +10 -1
  201. package/dist/internal/VerifiedTokens.js.map +1 -1
  202. package/dist/internal/Viem.d.ts +1 -1
  203. package/dist/internal/Webhooks.d.ts +24 -3
  204. package/dist/internal/Webhooks.d.ts.map +1 -1
  205. package/dist/internal/Webhooks.js +34 -8
  206. package/dist/internal/Webhooks.js.map +1 -1
  207. package/package.json +2 -2
  208. package/src/App.ts +9 -1
  209. package/src/Client.test-d.ts +638 -6
  210. package/src/admin/App.ts +4 -0
  211. package/src/admin/apps/early-access.test.ts +125 -0
  212. package/src/admin/apps/early-access.ts +175 -0
  213. package/src/admin/apps/verified-tokens.test.ts +201 -0
  214. package/src/admin/apps/verified-tokens.ts +532 -286
  215. package/src/admin/ui/plugin.ts +2 -1
  216. package/src/admin/ui/src/lib/early-access.ts +63 -0
  217. package/src/admin/ui/src/lib/verified-tokens.ts +79 -0
  218. package/src/admin/ui/src/routeTree.gen.ts +33 -2
  219. package/src/admin/ui/src/routes/__root.tsx +2 -1
  220. package/src/admin/ui/src/routes/early-access.tsx +157 -0
  221. package/src/admin/ui/src/routes/verified-tokens.tsx +230 -19
  222. package/src/admin/ui.generated.ts +48 -33
  223. package/src/apps/data/App.ts +22 -14
  224. package/src/apps/data/FundingQuoteProviders.test.ts +298 -0
  225. package/src/apps/data/FundingQuoteProviders.ts +257 -4
  226. package/src/apps/data/FxOracle.test.ts +118 -0
  227. package/src/apps/data/FxOracle.ts +153 -0
  228. package/src/apps/data/index.ts +1 -0
  229. package/src/apps/data/routes/activities.test.ts +173 -3
  230. package/src/apps/data/routes/activities.ts +403 -132
  231. package/src/apps/data/routes/addresses.ts +19 -9
  232. package/src/apps/data/routes/balances.test.ts +144 -11
  233. package/src/apps/data/routes/balances.ts +140 -51
  234. package/src/apps/data/routes/exchanges.test.ts +540 -9
  235. package/src/apps/data/routes/exchanges.ts +690 -103
  236. package/src/apps/data/routes/funding.test.ts +84 -0
  237. package/src/apps/data/routes/funding.ts +38 -6
  238. package/src/apps/data/routes/receipts.test.ts +125 -23
  239. package/src/apps/data/routes/receipts.ts +205 -100
  240. package/src/apps/data/routes/tokens.test.ts +43 -0
  241. package/src/apps/data/routes/tokens.ts +27 -12
  242. package/src/apps/data/routes/transactions.test.ts +72 -19
  243. package/src/apps/data/routes/transactions.ts +162 -92
  244. package/src/apps/data/routes/transfers.test.ts +146 -4
  245. package/src/apps/data/routes/transfers.ts +91 -13
  246. package/src/apps/data/routes/valuation.test.ts +422 -0
  247. package/src/apps/data/routes/valuation.ts +423 -0
  248. package/src/apps/data/routes/verified-tokens.ts +14 -2
  249. package/src/apps/data/routes/webhooks.test.ts +56 -8
  250. package/src/apps/data/routes/webhooks.ts +144 -27
  251. package/src/apps/index.ts +1 -1
  252. package/src/apps/management/App.ts +13 -0
  253. package/src/apps/management/routes/api-keys.test.ts +87 -0
  254. package/src/apps/management/routes/api-keys.ts +4 -3
  255. package/src/apps/management/routes/billing.test.ts +112 -1
  256. package/src/apps/management/routes/billing.ts +7 -2
  257. package/src/apps/management/routes/faucet.test.ts +79 -0
  258. package/src/apps/management/routes/faucet.ts +185 -0
  259. package/src/apps/management/routes/invitations.test.ts +172 -0
  260. package/src/apps/management/routes/invitations.ts +3 -0
  261. package/src/apps/management/routes/invite-links.test.ts +117 -0
  262. package/src/apps/management/routes/invite-links.ts +43 -1
  263. package/src/apps/management/routes/me.test.ts +140 -88
  264. package/src/apps/management/routes/me.ts +6 -0
  265. package/src/apps/management/routes/usage.test.ts +6 -0
  266. package/src/apps/management/routes/usage.ts +7 -0
  267. package/src/apps/management/routes/verified-token-requests.test.ts +377 -0
  268. package/src/apps/management/routes/verified-token-requests.ts +397 -0
  269. package/src/apps/management/routes/webhooks.test.ts +426 -0
  270. package/src/apps/management/routes/webhooks.ts +672 -0
  271. package/src/apps/mcp.test.ts +13 -0
  272. package/src/apps/mcp.ts +18 -5
  273. package/src/cli/Cli.test.ts +36 -0
  274. package/src/cli/Cli.ts +7 -2
  275. package/src/db/Db.test.ts +19 -0
  276. package/src/db/Db.ts +95 -0
  277. package/src/db/Schema.ts +53 -0
  278. package/src/db/tables/earlyAccess.ts +86 -0
  279. package/src/db/tables/invitations.ts +26 -1
  280. package/src/db/tables/inviteLinks.ts +22 -3
  281. package/src/db/tables/sponsoredTransactions.ts +4 -0
  282. package/src/db/tables/verifiedTokenRequests.test.ts +200 -0
  283. package/src/db/tables/verifiedTokenRequests.ts +182 -0
  284. package/src/db/tables/webhookSubscriptions.ts +31 -0
  285. package/src/internal/Auth.ts +73 -45
  286. package/src/internal/FundingQuotes.test.ts +143 -1
  287. package/src/internal/FundingQuotes.ts +95 -28
  288. package/src/internal/Log.test-d.ts +21 -0
  289. package/src/internal/Log.test.ts +50 -0
  290. package/src/internal/Log.ts +24 -1
  291. package/src/internal/MetricSink.test.ts +160 -0
  292. package/src/internal/MetricSink.ts +10 -0
  293. package/src/internal/OpenApi.ts +3 -0
  294. package/src/internal/Schema.ts +50 -5
  295. package/src/internal/Value.test.ts +20 -0
  296. package/src/internal/Value.ts +24 -1
  297. package/src/internal/VerifiedTokens.ts +9 -1
  298. package/src/internal/Webhooks.test.ts +140 -0
  299. package/src/internal/Webhooks.ts +53 -9
@@ -0,0 +1,79 @@
1
+ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
2
+
3
+ import * as TestApp from '../../../../test/App.js'
4
+ import * as Faucet from './faucet.js'
5
+ import * as Orgs from './orgs.js'
6
+
7
+ const origin = 'http://localhost'
8
+
9
+ function createApp() {
10
+ return TestApp.create({
11
+ rateLimit: { now: () => new Date('2026-01-01T00:00:00.000Z') },
12
+ session: { wallet: { origin } },
13
+ })
14
+ }
15
+
16
+ async function session(app: TestApp.signIn.App) {
17
+ const account = privateKeyToAccount(generatePrivateKey())
18
+ const { cookie } = await TestApp.signIn(app, account)
19
+ return cookie!
20
+ }
21
+
22
+ async function organization(app: ReturnType<typeof createApp>, cookie: string) {
23
+ const response = await app.request('/v1/orgs', {
24
+ body: JSON.stringify({ name: 'Acme' }),
25
+ headers: { 'content-type': 'application/json', cookie },
26
+ method: 'POST',
27
+ })
28
+ return TestApp.json(response, Orgs.schema.Organization)
29
+ }
30
+
31
+ describe('faucet', () => {
32
+ test('funds a member account and rate limits repeated funding', async () => {
33
+ const app = createApp()
34
+ const cookie = await session(app)
35
+ const org = await organization(app, cookie)
36
+ const recipient = privateKeyToAccount(generatePrivateKey()).address
37
+
38
+ const funded = await app.request(`/v1/orgs/${org.id}/faucet`, {
39
+ body: JSON.stringify({ address: recipient }),
40
+ headers: { 'content-type': 'application/json', cookie },
41
+ method: 'POST',
42
+ })
43
+ expect(funded.status).toBe(200)
44
+ const result = await TestApp.json(funded, Faucet.schema.FundResponse)
45
+ expect(result.tokens.length).toBeGreaterThan(0)
46
+ expect(result.tokens.every((token) => token.symbol.length > 0)).toBe(true)
47
+ expect(result.tokens.every((token) => BigInt(token.amount) > 0n)).toBe(true)
48
+ expect(result.transactionHashes).toEqual(result.tokens.map((token) => token.transactionHash))
49
+
50
+ const repeated = await app.request(`/v1/orgs/${org.id}/faucet`, {
51
+ body: JSON.stringify({ address: recipient }),
52
+ headers: { 'content-type': 'application/json', cookie },
53
+ method: 'POST',
54
+ })
55
+ expect(repeated.status).toBe(429)
56
+ expect(repeated.headers.get('ratelimit-scope')).toBe('faucet-address')
57
+ })
58
+
59
+ test('requires membership and a valid address', async () => {
60
+ const app = createApp()
61
+ const owner = await session(app)
62
+ const org = await organization(app, owner)
63
+ const stranger = await session(app)
64
+
65
+ const inaccessible = await app.request(`/v1/orgs/${org.id}/faucet`, {
66
+ body: JSON.stringify({ address: privateKeyToAccount(generatePrivateKey()).address }),
67
+ headers: { 'content-type': 'application/json', cookie: stranger },
68
+ method: 'POST',
69
+ })
70
+ expect(inaccessible.status).toBe(404)
71
+
72
+ const invalid = await app.request(`/v1/orgs/${org.id}/faucet`, {
73
+ body: JSON.stringify({ address: 'not-an-address' }),
74
+ headers: { 'content-type': 'application/json', cookie: owner },
75
+ method: 'POST',
76
+ })
77
+ expect(invalid.status).toBe(400)
78
+ })
79
+ })
@@ -0,0 +1,185 @@
1
+ import { Hono, type Context } from 'hono'
2
+ import * as z from 'zod/mini'
3
+
4
+ import * as Auth from '../../../internal/Auth.js'
5
+ import * as OpenApi from '../../../internal/OpenApi.js'
6
+ import * as RateLimit from '../../../internal/RateLimit.js'
7
+ import * as Response from '../../../internal/Response.js'
8
+ import * as Schema from '../../../internal/Schema.js'
9
+ import * as Viem from '../../../internal/Viem.js'
10
+ import type { Environment } from '../App.js'
11
+
12
+ const addressRateLimit = { perMinute: 1 } satisfies RateLimit.Limit
13
+ const userRateLimit = { perMinute: 3 } satisfies RateLimit.Limit
14
+
15
+ /** OpenAPI schemas owned by the sandbox faucet. */
16
+ export namespace schema {
17
+ /** Faucet request body. */
18
+ export const FundBody = Schema.describe(
19
+ z.object({ address: Schema.Address }),
20
+ 'A Tempo testnet account to fund.',
21
+ )
22
+
23
+ /** Faucet response. */
24
+ export const FundResponse = Schema.describe(
25
+ z.object({
26
+ tokens: z
27
+ .array(
28
+ z.object({
29
+ address: Schema.TokenAddress.check(
30
+ z.describe('The test token contract that minted funds.'),
31
+ ),
32
+ amount: Schema.DecimalString.check(
33
+ z.describe('The minted amount in the token’s smallest units.'),
34
+ ),
35
+ currency: z.string().check(z.describe('The token’s currency label.')),
36
+ decimals: z
37
+ .number()
38
+ .check(
39
+ z.int(),
40
+ z.nonnegative(),
41
+ z.describe('The number of decimal places the token uses.'),
42
+ ),
43
+ name: z.string().check(z.describe('The token’s human-readable name.')),
44
+ symbol: z.string().check(z.describe('The token’s symbol.')),
45
+ transactionHash: Schema.Hash.check(
46
+ z.describe('The transaction that minted this token.'),
47
+ ),
48
+ }),
49
+ )
50
+ .check(z.describe('The configured test tokens minted to the account.')),
51
+ transactionHashes: z
52
+ .array(Schema.Hash)
53
+ .check(z.describe('Transactions that minted the configured test tokens.')),
54
+ }),
55
+ 'Test tokens minted while funding the account.',
56
+ )
57
+
58
+ /** Faucet path parameters. */
59
+ export const Params = Schema.describe(
60
+ z.object({
61
+ orgId: z
62
+ .string()
63
+ .check(
64
+ z.describe('The organization id (`org_…`).'),
65
+ z.meta({ examples: ['org_1a2b3c4d5e6f7g8h9j0k1m2n'] }),
66
+ ),
67
+ }),
68
+ 'Path parameters for the organization faucet.',
69
+ )
70
+ }
71
+
72
+ const bodyValidation = {
73
+ code: 'body_invalid',
74
+ message: 'Enter a valid Tempo address.',
75
+ } as const
76
+ const paramValidation = {
77
+ code: 'param_invalid',
78
+ message: 'Check the path parameters and try again.',
79
+ } as const
80
+
81
+ /** Mounts the session-authenticated Tempo testnet faucet. */
82
+ export function faucet() {
83
+ return new Hono<Environment>().post(
84
+ '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/faucet',
85
+ Auth.policy({ session: true }),
86
+ Auth.ensureOrg(),
87
+ OpenApi.validate('param', schema.Params, paramValidation),
88
+ OpenApi.validate('json', schema.FundBody, bodyValidation),
89
+ OpenApi.describeRoute({
90
+ description:
91
+ 'Funds an account with the test tokens configured on Tempo testnet. This operation is available to signed-in organization members and is rate limited.',
92
+ operationId: 'fundTestnetAccount',
93
+ responses: OpenApi.responses({
94
+ errors: {
95
+ 400: {
96
+ codes: ['api_key_malformed', 'body_invalid', 'param_invalid'],
97
+ description: 'The path or account address is invalid.',
98
+ },
99
+ 404: 'No accessible organization was found.',
100
+ },
101
+ success: {
102
+ description: 'The account was funded.',
103
+ schema: schema.FundResponse,
104
+ },
105
+ }),
106
+ summary: 'Fund testnet account',
107
+ tags: ['Faucet'],
108
+ }),
109
+ async (c) => {
110
+ if (Auth.narrowAccess) return Auth.superAdminAccessError(c)
111
+ if (Auth.narrowScope) return Auth.ensureOrgError(c)
112
+ if (OpenApi.narrowValidation) return OpenApi.validationError(c, paramValidation)
113
+ if (OpenApi.narrowValidation) return OpenApi.validationError(c, bodyValidation)
114
+
115
+ const { address } = c.req.valid('json')
116
+ const limited = await consumeRateLimit(c, address)
117
+ if (limited) return rateLimitError(c, limited)
118
+
119
+ try {
120
+ const client = c.get('getClient')(Viem.chainId.testnet)
121
+ const receipts = await client.faucet.fundSync({ account: address, timeout: 30_000 })
122
+ const tokens = await Promise.all(
123
+ receipts.map(async (receipt) => {
124
+ const event = client.token.mint.extractEvent(receipt.logs)
125
+ const metadata = await client.token.getMetadata({ token: event.address })
126
+ return {
127
+ address: event.address,
128
+ amount: event.args.amount.toString(),
129
+ currency: metadata.currency,
130
+ decimals: metadata.decimals,
131
+ name: metadata.name,
132
+ symbol: metadata.symbol,
133
+ transactionHash: receipt.transactionHash,
134
+ }
135
+ }),
136
+ )
137
+ const transactionHashes = tokens.map((token) => token.transactionHash)
138
+ return c.json(Response.validated(schema.FundResponse, { tokens, transactionHashes }), 200)
139
+ } catch (cause) {
140
+ return Response.upstream(c, cause)
141
+ }
142
+ },
143
+ )
144
+ }
145
+
146
+ async function consumeRateLimit(c: Context<Environment>, address: string) {
147
+ const principal = Auth.getPrincipal(c)
148
+ const store = c.get('auth').rateLimit
149
+ if (!principal || !store) return undefined
150
+
151
+ const user = await store.consume({
152
+ key: `faucet:user:${principal.id}`,
153
+ limit: userRateLimit,
154
+ })
155
+ if (!user.allowed) return { result: user, scope: 'faucet-user' }
156
+
157
+ const account = await store.consume({
158
+ key: `faucet:address:${address}`,
159
+ limit: addressRateLimit,
160
+ })
161
+ if (!account.allowed) return { result: account, scope: 'faucet-address' }
162
+ return undefined
163
+ }
164
+
165
+ function rateLimitError(
166
+ c: Context<Environment>,
167
+ limited: {
168
+ /** Denied quota result. */
169
+ result: RateLimit.Result
170
+ /** Quota dimension that denied the request. */
171
+ scope: string
172
+ },
173
+ ) {
174
+ const { result, scope } = limited
175
+ c.header('RateLimit-Limit', String(result.limit))
176
+ c.header('RateLimit-Remaining', String(result.remaining))
177
+ c.header('RateLimit-Reset', String(result.reset))
178
+ c.header('RateLimit-Scope', scope)
179
+ c.header('Retry-After', String(Math.max(result.reset - Math.ceil(Date.now() / 1_000), 1)))
180
+ return Response.error(c, {
181
+ code: 'rate_limit_exceeded',
182
+ message: 'This account was funded recently. Try again after the cooldown.',
183
+ status: 429,
184
+ })
185
+ }
@@ -2,6 +2,7 @@ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
2
2
 
3
3
  import * as App from '../App.js'
4
4
  import * as TestApp from '../../../../test/App.js'
5
+ import * as EarlyAccess from '../../../db/tables/earlyAccess.js'
5
6
  import * as Invitations from '../../../db/tables/invitations.js'
6
7
  import * as InvitationsApp from './invitations.js'
7
8
  import * as Memberships from '../../../db/tables/memberships.js'
@@ -50,6 +51,7 @@ function createApp(
50
51
  db?: ReturnType<typeof TestApp.database>
51
52
  email?: App.Email
52
53
  kv?: { store: ReturnType<typeof TestApp.kvStore> }
54
+ tmp_earlyAccess?: boolean
53
55
  } = {},
54
56
  ) {
55
57
  return TestApp.create({
@@ -58,6 +60,7 @@ function createApp(
58
60
  ...(options.email ? { email: options.email } : {}),
59
61
  ...(options.kv ? { kv: options.kv } : {}),
60
62
  session: { wallet: { origin } },
63
+ ...(options.tmp_earlyAccess === undefined ? {} : { tmp_earlyAccess: options.tmp_earlyAccess }),
61
64
  })
62
65
  }
63
66
 
@@ -346,6 +349,7 @@ describe('invitations', () => {
346
349
  const expired = await Invitations.create(db, {
347
350
  email: 'dev@example.com',
348
351
  expiresAt: '2020-01-01T00:00:00.000Z',
352
+ grantsEarlyAccess: false,
349
353
  invitedBy: owner.userId,
350
354
  orgId: org.id,
351
355
  role: 'member',
@@ -508,3 +512,171 @@ describe('invitations', () => {
508
512
  )
509
513
  })
510
514
  })
515
+
516
+ describe('early access stamping', () => {
517
+ test('does not persist a dormant gate bypass', async () => {
518
+ const db = TestApp.database()
519
+ const app = createApp({ db })
520
+ const owner = await session(app)
521
+ await Users.setEmail(db, owner.userId, 'owner@example.net')
522
+ const created = await app.request('/v1/orgs', json('POST', owner.cookie, { name: 'Acme' }))
523
+ const org = await TestApp.json(created, Orgs.schema.Organization)
524
+
525
+ const invited = await app.request(
526
+ `/v1/orgs/${org.id}/invitations`,
527
+ json('POST', owner.cookie, { email: 'owner@example.net', role: 'member' }),
528
+ )
529
+ const invitation = await TestApp.json(invited, InvitationsApp.schema.Invitation)
530
+
531
+ expect((await Invitations.get(db, invitation.id))?.grantsEarlyAccess).toBe(false)
532
+ })
533
+
534
+ test('stamps grants_early_access from the session inviter', async () => {
535
+ const db = TestApp.database()
536
+ const app = createApp({ db, tmp_earlyAccess: true })
537
+ await EarlyAccess.add(db, { createdBy: 'test', entry: 'example.org' })
538
+
539
+ // A non-early-access inviter stamps no grant.
540
+ const plain = await session(app)
541
+ const createdPlain = await app.request('/v1/orgs', json('POST', plain.cookie, { name: 'Plain' })) // prettier-ignore
542
+ const orgPlain = await TestApp.json(createdPlain, Orgs.schema.Organization)
543
+ const invitedPlain = await app.request(
544
+ `/v1/orgs/${orgPlain.id}/invitations`,
545
+ json('POST', plain.cookie, { email: 'a@example.net', role: 'member' }),
546
+ )
547
+ const invitationPlain = await TestApp.json(invitedPlain, InvitationsApp.schema.Invitation)
548
+ expect((await Invitations.get(db, invitationPlain.id))?.grantsEarlyAccess).toBe(false)
549
+
550
+ // An early-access inviter stamps the grant.
551
+ const granted = await session(app)
552
+ await Users.setEmail(db, granted.userId, 'owner@example.org')
553
+ const createdGranted = await app.request('/v1/orgs', json('POST', granted.cookie, { name: 'Granted' })) // prettier-ignore
554
+ const orgGranted = await TestApp.json(createdGranted, Orgs.schema.Organization)
555
+ const invitedGranted = await app.request(
556
+ `/v1/orgs/${orgGranted.id}/invitations`,
557
+ json('POST', granted.cookie, { email: 'b@example.net', role: 'member' }),
558
+ )
559
+ const invitationGranted = await TestApp.json(invitedGranted, InvitationsApp.schema.Invitation)
560
+ expect((await Invitations.get(db, invitationGranted.id))?.grantsEarlyAccess).toBe(true)
561
+ })
562
+
563
+ test('stamps grants_early_access true for API-key and super-admin inviters', async () => {
564
+ const db = TestApp.database()
565
+ await Organizations.create(db, { id: management.orgId, name: 'Acme' })
566
+ const app = createApp({
567
+ db,
568
+ tmp_earlyAccess: true,
569
+ kv: { store: TestApp.kvStore({ keys: [management] }) },
570
+ })
571
+
572
+ const viaKey = await app.request(`/v1/orgs/${management.orgId}/invitations`, {
573
+ body: JSON.stringify({ email: 'key@example.net', role: 'member' }),
574
+ headers: { 'content-type': 'application/json', 'tempo-api-key': management.token },
575
+ method: 'POST',
576
+ })
577
+ expect(viaKey.status).toBe(200)
578
+ const keyInvitation = await TestApp.json(viaKey, InvitationsApp.schema.Invitation)
579
+ expect((await Invitations.get(db, keyInvitation.id))?.grantsEarlyAccess).toBe(true)
580
+
581
+ const created = await app.request('/v1/orgs', {
582
+ body: JSON.stringify({ name: 'Provisioned' }),
583
+ headers: { 'content-type': 'application/json', 'tempo-api-key': secret },
584
+ method: 'POST',
585
+ })
586
+ const org = await TestApp.json(created, Orgs.schema.Organization)
587
+ const viaAdmin = await app.request(`/v1/orgs/${org.id}/invitations`, {
588
+ body: JSON.stringify({ email: 'admin@example.net', role: 'owner' }),
589
+ headers: { 'content-type': 'application/json', 'tempo-api-key': secret },
590
+ method: 'POST',
591
+ })
592
+ expect(viaAdmin.status).toBe(200)
593
+ const adminInvitation = await TestApp.json(viaAdmin, InvitationsApp.schema.Invitation)
594
+ expect((await Invitations.get(db, adminInvitation.id))?.grantsEarlyAccess).toBe(true)
595
+ })
596
+
597
+ test('re-invites upgrade the grant and never downgrade it', async () => {
598
+ const db = TestApp.database()
599
+ const app = createApp({ db, tmp_earlyAccess: true })
600
+ await EarlyAccess.add(db, { createdBy: 'test', entry: 'example.org' })
601
+ const owner = await session(app)
602
+ const created = await app.request('/v1/orgs', json('POST', owner.cookie, { name: 'Acme' }))
603
+ const org = await TestApp.json(created, Orgs.schema.Organization)
604
+ const admin = await session(app)
605
+ await Users.setEmail(db, admin.userId, 'admin@example.org')
606
+ await Memberships.create(db, { orgId: org.id, role: 'admin', userId: admin.userId })
607
+
608
+ const first = await app.request(
609
+ `/v1/orgs/${org.id}/invitations`,
610
+ json('POST', owner.cookie, { email: 'x@example.net', role: 'member' }),
611
+ )
612
+ const firstInvite = await TestApp.json(first, InvitationsApp.schema.Invitation)
613
+ expect((await Invitations.get(db, firstInvite.id))?.grantsEarlyAccess).toBe(false)
614
+
615
+ // An early-access re-invite upgrades the same pending row.
616
+ const second = await app.request(
617
+ `/v1/orgs/${org.id}/invitations`,
618
+ json('POST', admin.cookie, { email: 'x@example.net', role: 'member' }),
619
+ )
620
+ const secondInvite = await TestApp.json(second, InvitationsApp.schema.Invitation)
621
+ expect(secondInvite.id).toBe(firstInvite.id)
622
+ expect((await Invitations.get(db, firstInvite.id))?.grantsEarlyAccess).toBe(true)
623
+
624
+ // A later non-early-access re-invite never withdraws the grant.
625
+ const third = await app.request(
626
+ `/v1/orgs/${org.id}/invitations`,
627
+ json('POST', owner.cookie, { email: 'x@example.net', role: 'member' }),
628
+ )
629
+ expect(third.status).toBe(200)
630
+ expect((await Invitations.get(db, firstInvite.id))?.grantsEarlyAccess).toBe(true)
631
+ })
632
+
633
+ test('hasEarlyAccessGrant survives acceptance and expiry, dies on revoke', async () => {
634
+ const db = TestApp.database()
635
+ const app = createApp({ db, tmp_earlyAccess: true })
636
+ await EarlyAccess.add(db, { createdBy: 'test', entry: 'example.org' })
637
+ const owner = await session(app)
638
+ await Users.setEmail(db, owner.userId, 'owner@example.org')
639
+ const created = await app.request('/v1/orgs', json('POST', owner.cookie, { name: 'Acme' }))
640
+ const org = await TestApp.json(created, Orgs.schema.Organization)
641
+
642
+ // Acceptance settles the invitation but keeps the grant.
643
+ const invited = await app.request(
644
+ `/v1/orgs/${org.id}/invitations`,
645
+ json('POST', owner.cookie, { email: 'z@example.net', role: 'member' }),
646
+ )
647
+ const invitation = await TestApp.json(invited, InvitationsApp.schema.Invitation)
648
+ const invitee = await session(app)
649
+ await Users.setEmail(db, invitee.userId, 'z@example.net')
650
+ const accepted = await app.request(`/v1/invitations/${invitation.id}/accept`, {
651
+ headers: { cookie: invitee.cookie },
652
+ method: 'POST',
653
+ })
654
+ expect(accepted.status).toBe(200)
655
+ expect(await Invitations.hasEarlyAccessGrant(db, 'z@example.net')).toBe(true)
656
+
657
+ // Expiry is ignored by the grant lookup.
658
+ await Invitations.create(db, {
659
+ email: 'e@example.net',
660
+ expiresAt: '2020-01-01T00:00:00.000Z',
661
+ grantsEarlyAccess: true,
662
+ invitedBy: owner.userId,
663
+ orgId: org.id,
664
+ role: 'member',
665
+ })
666
+ expect(await Invitations.hasEarlyAccessGrant(db, 'e@example.net')).toBe(true)
667
+
668
+ // Revocation withdraws the grant.
669
+ const pending = await app.request(
670
+ `/v1/orgs/${org.id}/invitations`,
671
+ json('POST', owner.cookie, { email: 'r@example.net', role: 'member' }),
672
+ )
673
+ const pendingInvite = await TestApp.json(pending, InvitationsApp.schema.Invitation)
674
+ expect(await Invitations.hasEarlyAccessGrant(db, 'r@example.net')).toBe(true)
675
+ const revoked = await app.request(`/v1/orgs/${org.id}/invitations/${pendingInvite.id}`, {
676
+ headers: { cookie: owner.cookie },
677
+ method: 'DELETE',
678
+ })
679
+ expect(revoked.status).toBe(200)
680
+ expect(await Invitations.hasEarlyAccessGrant(db, 'r@example.net')).toBe(false)
681
+ })
682
+ })
@@ -261,12 +261,15 @@ export function invitations() {
261
261
  const principal = Auth.getPrincipal(c)
262
262
  const invitedBy =
263
263
  principal?.type === 'api_key' ? principal.id : (caller?.userId ?? 'super_admin')
264
+ // Elevation transfers only from inviters who themselves have early access.
265
+ const grantsEarlyAccess = await Auth.tmp_earlyAccessGrant(c)
264
266
 
265
267
  const db = Db.get(c.get('db'))
266
268
  try {
267
269
  const record = await Invitations.create(db, {
268
270
  email: body.email,
269
271
  expiresAt: new Date(Date.now() + ttlDays * 86_400_000).toISOString(),
272
+ grantsEarlyAccess,
270
273
  invitedBy,
271
274
  orgId: Auth.org(c).id,
272
275
  role: body.role,
@@ -82,6 +82,7 @@ describe('inviteLinks', () => {
82
82
 
83
83
  const link = await createLink(context, org.id, admin.cookie, { role: 'member' })
84
84
  expect(link).toMatchObject({
85
+ allowedEmailDomains: null,
85
86
  enabled: true,
86
87
  lastUsedAt: null,
87
88
  maxUses: null,
@@ -143,6 +144,122 @@ describe('inviteLinks', () => {
143
144
  expect((await listed.json()).data).toEqual([])
144
145
  })
145
146
 
147
+ test('normalizes and validates allowed email domains', async () => {
148
+ const context = createApp()
149
+ const { client } = context
150
+ const { org, owner } = await setup(context)
151
+ const orgLinks = client.v1.orgs[':orgId{org_[A-Za-z0-9_-]+}']['invite-links']
152
+
153
+ const link = await createLink(context, org.id, owner.cookie, {
154
+ allowedEmailDomains: [' Example.COM ', 'example.com', 'TEMPO.XYZ'],
155
+ name: 'Restricted',
156
+ })
157
+ expect(link.allowedEmailDomains).toEqual(['example.com', 'tempo.xyz'])
158
+
159
+ const listed = await orgLinks.$get({ param: { orgId: org.id } }, as(owner.cookie))
160
+ expect(listed.status).toBe(200)
161
+ if (listed.status !== 200) throw new Error(`Failed to list invite links: ${listed.status}`)
162
+ expect((await listed.json()).data[0]?.allowedEmailDomains).toEqual(['example.com', 'tempo.xyz'])
163
+
164
+ for (const allowedEmailDomains of [
165
+ [],
166
+ ['user@example.com'],
167
+ ['https://example.com'],
168
+ ['*.example.com'],
169
+ ]) {
170
+ const response = await orgLinks.$post(
171
+ {
172
+ json: { allowedEmailDomains },
173
+ param: { orgId: org.id },
174
+ },
175
+ as(owner.cookie),
176
+ )
177
+ expect(response.status).toBe(400)
178
+ if (response.status !== 400)
179
+ throw new Error(`Expected invalid invite link body, received ${response.status}`)
180
+ expect((await response.json()).error.code).toBe('body_invalid')
181
+ }
182
+ })
183
+
184
+ test('restricts new memberships without consuming rejected attempts', async () => {
185
+ const db = TestApp.database()
186
+ const context = createApp(db)
187
+ const { client } = context
188
+ const { org, owner } = await setup(context)
189
+ const orgLinks = client.v1.orgs[':orgId{org_[A-Za-z0-9_-]+}']['invite-links']
190
+ const publicLinks = client.v1['invite-links']
191
+ const link = await createLink(context, org.id, owner.cookie, {
192
+ allowedEmailDomains: [' Example.COM '],
193
+ expiresAt: null,
194
+ maxUses: 1,
195
+ name: 'Restricted',
196
+ })
197
+
198
+ const member = await session(context)
199
+ await Users.setEmail(db, member.userId, 'member@sub.example.com')
200
+ await Memberships.create(db, { orgId: org.id, role: 'member', userId: member.userId })
201
+ const memberRetry = await publicLinks.accept.$post(
202
+ { json: { token: link.token } },
203
+ as(member.cookie),
204
+ )
205
+ expect(memberRetry.status).toBe(200)
206
+
207
+ const subdomain = await session(context)
208
+ await Users.setEmail(db, subdomain.userId, 'dev@sub.example.com')
209
+ const rejected = await publicLinks.accept.$post(
210
+ { json: { token: link.token } },
211
+ as(subdomain.cookie),
212
+ )
213
+ expect(rejected.status).toBe(403)
214
+ if (rejected.status !== 403)
215
+ throw new Error(`Expected forbidden invite link acceptance, received ${rejected.status}`)
216
+ expect((await rejected.json()).error.code).toBe('invite_link_email_forbidden')
217
+ expect(await Memberships.get(db, org.id, subdomain.userId)).toBeUndefined()
218
+
219
+ const audits = await orgLinks[':inviteLinkId{iln_[A-Za-z0-9_-]+}'].redemptions.$get(
220
+ { param: { inviteLinkId: link.id, orgId: org.id } },
221
+ as(owner.cookie),
222
+ )
223
+ expect(audits.status).toBe(200)
224
+ if (audits.status !== 200) throw new Error(`Failed to list redemptions: ${audits.status}`)
225
+ expect((await audits.json()).data).toEqual([])
226
+
227
+ const beforeJoin = await orgLinks.$get({ param: { orgId: org.id } }, as(owner.cookie))
228
+ expect(beforeJoin.status).toBe(200)
229
+ if (beforeJoin.status !== 200)
230
+ throw new Error(`Failed to list invite links: ${beforeJoin.status}`)
231
+ expect((await beforeJoin.json()).data[0]).toMatchObject({ status: 'active', useCount: 0 })
232
+
233
+ const exact = await session(context)
234
+ await Users.setEmail(db, exact.userId, 'Dev@Example.com')
235
+ const accepted = await publicLinks.accept.$post(
236
+ { json: { token: link.token } },
237
+ as(exact.cookie),
238
+ )
239
+ expect(accepted.status).toBe(200)
240
+
241
+ const exhaustedRetry = await publicLinks.accept.$post(
242
+ { json: { token: link.token } },
243
+ as(member.cookie),
244
+ )
245
+ expect(exhaustedRetry.status).toBe(200)
246
+
247
+ const afterJoin = await orgLinks.$get({ param: { orgId: org.id } }, as(owner.cookie))
248
+ expect(afterJoin.status).toBe(200)
249
+ if (afterJoin.status !== 200)
250
+ throw new Error(`Failed to list invite links: ${afterJoin.status}`)
251
+ expect((await afterJoin.json()).data[0]).toMatchObject({ status: 'exhausted', useCount: 1 })
252
+
253
+ const auditsAfterJoin = await orgLinks[':inviteLinkId{iln_[A-Za-z0-9_-]+}'].redemptions.$get(
254
+ { param: { inviteLinkId: link.id, orgId: org.id } },
255
+ as(owner.cookie),
256
+ )
257
+ expect(auditsAfterJoin.status).toBe(200)
258
+ if (auditsAfterJoin.status !== 200)
259
+ throw new Error(`Failed to list redemptions: ${auditsAfterJoin.status}`)
260
+ expect((await auditsAfterJoin.json()).data).toHaveLength(1)
261
+ })
262
+
146
263
  test('resolve reveals only a summary and hides every unavailable reason', async () => {
147
264
  const db = TestApp.database()
148
265
  const context = createApp(db)