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
@@ -1,14 +1,23 @@
1
- import { Hono } from 'hono'
1
+ import { Hono, type Context } from 'hono'
2
2
  import { requestId } from 'hono/request-id'
3
+ import { Challenge, Receipt } from 'mppx'
3
4
  import { Mppx, tempo } from 'mppx/hono'
4
- import { RateLimit } from 'tapimo'
5
+ import type { Session } from 'mppx/tempo'
6
+ import { RateLimit, Store } from 'tapimo'
7
+ import { Actions } from 'viem/tempo'
5
8
  import * as ApiKey from '../ApiKey.js'
9
+ import type * as Db from '../db/Db.js'
10
+ import * as Memberships from '../db/tables/memberships.js'
11
+ import * as Organizations from '../db/tables/organizations.js'
12
+ import * as Projects from '../db/tables/projects.js'
13
+ import * as Users from '../db/tables/users.js'
6
14
  import * as Auth from './Auth.js'
7
15
  import * as Log from './Log.js'
8
16
  import * as Scope from '../Scope.js'
9
17
  import * as Viem from './Viem.js'
10
18
  import * as TestApp from '../../test/App.js'
11
19
  import { createClient as createMppClient } from '../../test/Mppx.js'
20
+ import * as TestStore from '../../test/Store.js'
12
21
  import * as Tempo from '../../test/Tempo.js'
13
22
 
14
23
  const key = {
@@ -75,12 +84,13 @@ describe('require', () => {
75
84
  `)
76
85
  })
77
86
 
78
- test('accepts API key from deprecated key query param', async () => {
87
+ test('accepts API key from key query param', async () => {
79
88
  const app = createApp()
80
89
  const response = await app.request(`/protected?key=${token}`)
81
90
  const body = await response.json()
82
91
 
83
92
  expect(response.status).toMatchInlineSnapshot(`200`)
93
+ expect(response.headers.get('cache-control')).toMatchInlineSnapshot(`"private"`)
84
94
  expect(body).toMatchInlineSnapshot(`
85
95
  {
86
96
  "id": "key_test",
@@ -90,11 +100,30 @@ describe('require', () => {
90
100
  `)
91
101
  })
92
102
 
93
- test('strips the deprecated key query param before route validation', async () => {
94
- // Regression: route query schemas are strict (`z.strictObject`), so the
95
- // `key` auth param must be removed from the URL once consumed — otherwise
96
- // the downstream validator rejects it as `query_invalid` and the legacy
97
- // `?key=...` lane breaks on every strict-query endpoint.
103
+ test('keeps query-authenticated responses private after downstream caching', async () => {
104
+ const auth = {
105
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
106
+ rateLimit: RateLimit.memory(),
107
+ }
108
+ const app = new Hono<Auth.Environment>()
109
+ app.use('*', async (c, next) => {
110
+ c.set('auth', auth)
111
+ await next()
112
+ })
113
+ app.use('/cached', Auth.require({ apiKey: { scopes: ['data:read'] } }))
114
+ app.get('/cached', (c) => {
115
+ c.header('Cache-Control', 'public, max-age=86400')
116
+ return c.json({ ok: true })
117
+ })
118
+
119
+ const response = await app.request(`/cached?key=${token}`)
120
+
121
+ expect(response.status).toMatchInlineSnapshot(`200`)
122
+ expect(response.headers.get('cache-control')).toMatchInlineSnapshot(`"private"`)
123
+ })
124
+
125
+ test('strips the key query param before route validation', async () => {
126
+ // Auth owns `key`, so strict route schemas must not see it.
98
127
  const auth = {
99
128
  apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
100
129
  rateLimit: RateLimit.memory(),
@@ -534,7 +563,7 @@ describe('require', () => {
534
563
  const policy: Auth.require.Policy = {
535
564
  apiKey: { scopes: [Scope.wildcard] },
536
565
  ...(options.public === false ? {} : { public: { rateLimit: { perMinute: 60 } } }),
537
- ...(options.mpp ? { mpp: { charge: { amount: '1' } } } : {}),
566
+ ...(options.mpp ? { mpp: { session: { amount: '1' } } } : {}),
538
567
  }
539
568
  const app = new Hono<Auth.Environment>()
540
569
  app.use('*', async (c, next) => {
@@ -714,6 +743,121 @@ describe('require', () => {
714
743
  })
715
744
 
716
745
  describe('mpp', () => {
746
+ test('uses a default paid-request limit of 100 per minute', async () => {
747
+ const rateLimit = underQuota()
748
+ const consume = vi.spyOn(rateLimit, 'consume')
749
+ const app = createApp({
750
+ auth: {
751
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
752
+ mpp: createMppHandler(),
753
+ rateLimit,
754
+ },
755
+ policy: { mpp: { session: { amount: '1' } } },
756
+ })
757
+ const response = await app.request('/protected', {
758
+ headers: {
759
+ authorization: 'Payment paid',
760
+ 'x-forwarded-for': '203.0.113.10',
761
+ },
762
+ })
763
+
764
+ expect(response.status).toBe(200)
765
+ expect(response.headers.get('RateLimit-Limit')).toBe('100')
766
+ expect(response.headers.get('RateLimit-Scope')).toBe('mpp')
767
+ expect(consume).toHaveBeenCalledWith({
768
+ key: 'mpp:public:203.0.113.10',
769
+ limit: { perMinute: 100 },
770
+ })
771
+ })
772
+
773
+ test('buckets paid API-key requests by key ID', async () => {
774
+ const rateLimit = underQuota()
775
+ const consume = vi.spyOn(rateLimit, 'consume')
776
+ const app = createApp({
777
+ auth: {
778
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
779
+ mpp: createMppHandler(),
780
+ rateLimit,
781
+ },
782
+ policy: {
783
+ apiKey: { scopes: ['data:read'] },
784
+ mpp: { session: { amount: '1' } },
785
+ },
786
+ })
787
+ const response = await app.request('/protected', {
788
+ headers: {
789
+ authorization: 'Payment paid',
790
+ 'tempo-api-key': token,
791
+ 'x-forwarded-for': '203.0.113.10',
792
+ },
793
+ })
794
+
795
+ expect(response.status).toBe(200)
796
+ expect(consume).toHaveBeenCalledWith({
797
+ key: 'mpp:api_key:key_test',
798
+ limit: { perMinute: 100 },
799
+ })
800
+ })
801
+
802
+ test('uses the configured global paid-request limit', async () => {
803
+ const app = createApp({
804
+ auth: {
805
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
806
+ mpp: createMppHandler({ rateLimit: { perMinute: 25 } }),
807
+ rateLimit: underQuota(),
808
+ },
809
+ policy: { mpp: { session: { amount: '1' } } },
810
+ })
811
+ const response = await app.request('/protected', {
812
+ headers: { authorization: 'Payment paid' },
813
+ })
814
+
815
+ expect(response.status).toBe(200)
816
+ expect(response.headers.get('RateLimit-Limit')).toBe('25')
817
+ })
818
+
819
+ test('route paid-request limit overrides the global limit', async () => {
820
+ const app = createApp({
821
+ auth: {
822
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
823
+ mpp: createMppHandler({ rateLimit: { perMinute: 25 } }),
824
+ rateLimit: underQuota(),
825
+ },
826
+ policy: {
827
+ mpp: {
828
+ rateLimit: { perMinute: 5 },
829
+ session: { amount: '1' },
830
+ },
831
+ },
832
+ })
833
+ const response = await app.request('/protected', {
834
+ headers: { authorization: 'Payment paid' },
835
+ })
836
+
837
+ expect(response.status).toBe(200)
838
+ expect(response.headers.get('RateLimit-Limit')).toBe('5')
839
+ })
840
+
841
+ test('rejects paid requests over quota before accepting payment', async () => {
842
+ const onPayment = vi.fn()
843
+ const app = createApp({
844
+ auth: {
845
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
846
+ mpp: createMppHandler({ onPayment }),
847
+ rateLimit: overQuota(),
848
+ },
849
+ policy: { mpp: { session: { amount: '1' } } },
850
+ })
851
+ const response = await app.request('/protected', {
852
+ headers: { authorization: 'Payment paid' },
853
+ })
854
+
855
+ expect(response.status).toBe(429)
856
+ expect(response.headers.get('RateLimit-Limit')).toBe('100')
857
+ expect((await response.json()).error.code).toBe('rate_limit_exceeded')
858
+ expect(onPayment).not.toHaveBeenCalled()
859
+ })
860
+
717
861
  test('returns payment challenge for public over-quota requests', async () => {
718
862
  const mpp = createMppServer()
719
863
  const app = createApp({
@@ -723,7 +867,7 @@ describe('require', () => {
723
867
  rateLimit: overQuota(),
724
868
  },
725
869
  policy: {
726
- mpp: { charge: { amount: '1', description: 'Protected data' } },
870
+ mpp: { session: { amount: '1', description: 'Protected data' } },
727
871
  public: { rateLimit: { perMinute: 1 } },
728
872
  },
729
873
  })
@@ -735,16 +879,16 @@ describe('require', () => {
735
879
  ).toMatchInlineSnapshot(`true`)
736
880
  })
737
881
 
738
- test('accepts paid public retry without consuming quota', async () => {
882
+ test('accepts paid public retry without consuming public quota', async () => {
739
883
  const mpp = createMppServer()
740
884
  const app = createApp({
741
885
  auth: {
742
886
  apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
743
887
  mpp,
744
- rateLimit: overQuotaOnce(),
888
+ rateLimit: overQuotaExceptMpp(),
745
889
  },
746
890
  policy: {
747
- mpp: { charge: { amount: '1', description: 'Protected data' } },
891
+ mpp: { session: { amount: '1', description: 'Protected data' } },
748
892
  public: { rateLimit: { perMinute: 1 } },
749
893
  },
750
894
  })
@@ -779,7 +923,7 @@ describe('require', () => {
779
923
  rateLimit: { perMinute: 1 },
780
924
  scopes: ['data:read'],
781
925
  },
782
- mpp: { charge: { amount: '1', description: 'Protected data' } },
926
+ mpp: { session: { amount: '1', description: 'Protected data' } },
783
927
  },
784
928
  })
785
929
  const response = await app.request('/protected', {
@@ -798,14 +942,14 @@ describe('require', () => {
798
942
  auth: {
799
943
  apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
800
944
  mpp,
801
- rateLimit: overQuotaOnce(),
945
+ rateLimit: overQuotaExceptMpp(),
802
946
  },
803
947
  policy: {
804
948
  apiKey: {
805
949
  rateLimit: { perMinute: 1 },
806
950
  scopes: ['data:read'],
807
951
  },
808
- mpp: { charge: { amount: '1', description: 'Protected data' } },
952
+ mpp: { session: { amount: '1', description: 'Protected data' } },
809
953
  },
810
954
  })
811
955
  const response = await createMppClient(app).fetch('http://tempo-api.test/protected', {
@@ -830,6 +974,220 @@ describe('require', () => {
830
974
  }
831
975
  `)
832
976
  })
977
+
978
+ test('session challenge advertises the configured request defaults', async () => {
979
+ const mpp = createMppServer()
980
+ const app = createApp({
981
+ auth: {
982
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
983
+ mpp,
984
+ rateLimit: overQuota(),
985
+ },
986
+ policy: {
987
+ mpp: { session: { amount: '1', description: 'Protected data' } },
988
+ public: { rateLimit: { perMinute: 1 } },
989
+ },
990
+ })
991
+ const response = await app.request('/protected')
992
+ const challenge = Challenge.fromHeaders(response.headers)
993
+
994
+ expect(response.status).toBe(402)
995
+ expect(challenge.method).toBe('tempo')
996
+ expect(challenge.intent).toBe('session')
997
+ expect(challenge.realm).toBe('tempo-api-test')
998
+ expect(challenge.description).toBe('Protected data')
999
+ expect(challenge.request).toMatchObject({
1000
+ // Wire amounts are raw token units: '1' at 6 decimals.
1001
+ amount: '1000000',
1002
+ currency: Tempo.currency,
1003
+ methodDetails: {
1004
+ chainId: Tempo.chain.id,
1005
+ escrowContract: '0x4d50500000000000000000000000000000000000',
1006
+ sessionProtocol: 'v2',
1007
+ },
1008
+ recipient: Tempo.accounts[2].address,
1009
+ unitType: 'request',
1010
+ })
1011
+ })
1012
+
1013
+ test('route session overrides propagate to the challenge', async () => {
1014
+ const mpp = createMppServer()
1015
+ const app = createApp({
1016
+ auth: {
1017
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
1018
+ mpp,
1019
+ rateLimit: overQuota(),
1020
+ },
1021
+ policy: {
1022
+ mpp: { session: { amount: '2.5', suggestedDeposit: '10' } },
1023
+ public: { rateLimit: { perMinute: 1 } },
1024
+ },
1025
+ })
1026
+ const response = await app.request('/protected')
1027
+ const challenge = Challenge.fromHeaders(response.headers)
1028
+
1029
+ expect(response.status).toBe(402)
1030
+ expect(challenge.request).toMatchObject({
1031
+ // Wire amounts are raw token units at 6 decimals.
1032
+ amount: '2500000',
1033
+ suggestedDeposit: '10000000',
1034
+ })
1035
+ })
1036
+
1037
+ test('paid retry opens a funded on-chain channel', async () => {
1038
+ const mpp = createMppServer()
1039
+ const app = createApp({
1040
+ auth: {
1041
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
1042
+ mpp,
1043
+ rateLimit: overQuotaExceptMpp(),
1044
+ },
1045
+ policy: {
1046
+ mpp: { session: { amount: '1', description: 'Protected data' } },
1047
+ public: { rateLimit: { perMinute: 1 } },
1048
+ },
1049
+ })
1050
+ const response = await createMppClient(app).fetch('http://tempo-api.test/protected')
1051
+
1052
+ expect(response.status).toBe(200)
1053
+ const receipt = sessionReceipt(response)
1054
+ expect(receipt.method).toBe('tempo')
1055
+ expect(receipt.intent).toBe('session')
1056
+ expect(receipt.status).toBe('success')
1057
+ expect(receipt.channelId).toMatch(/^0x[0-9a-fA-F]{64}$/)
1058
+ // One paid request at amount '1' with 6 decimals.
1059
+ expect(receipt.spent).toBe('1000000')
1060
+ expect(BigInt(receipt.acceptedCumulative)).toBeGreaterThanOrEqual(1_000_000n)
1061
+
1062
+ const state = await Actions.channel.getStates(Tempo.client, {
1063
+ channel: receipt.channelId,
1064
+ })
1065
+ // The client deposits the request amount when no deposit is suggested.
1066
+ expect(state.deposit).toBe(1_000_000n)
1067
+ // No settlement schedule configured, so nothing settles inline.
1068
+ expect(state.settled).toBe(0n)
1069
+ expect(state.closeRequestedAt).toBe(0)
1070
+ })
1071
+
1072
+ test('reuses the session channel across paid requests', async () => {
1073
+ const mpp = createMppServer()
1074
+ const app = createApp({
1075
+ auth: {
1076
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
1077
+ mpp,
1078
+ rateLimit: overQuotaExceptMpp(),
1079
+ },
1080
+ policy: {
1081
+ // The suggested deposit pre-funds the channel for both requests.
1082
+ mpp: { session: { amount: '1', suggestedDeposit: '2' } },
1083
+ public: { rateLimit: { perMinute: 1 } },
1084
+ },
1085
+ })
1086
+ const client = createMppClient(app)
1087
+
1088
+ const first = await client.fetch('http://tempo-api.test/protected')
1089
+ const second = await client.fetch('http://tempo-api.test/protected')
1090
+
1091
+ expect(first.status).toBe(200)
1092
+ expect(second.status).toBe(200)
1093
+
1094
+ const receipt1 = sessionReceipt(first)
1095
+ const receipt2 = sessionReceipt(second)
1096
+ expect(receipt2.channelId).toBe(receipt1.channelId)
1097
+ expect(BigInt(receipt2.acceptedCumulative)).toBeGreaterThan(
1098
+ BigInt(receipt1.acceptedCumulative),
1099
+ )
1100
+ expect(receipt2.spent).toBe('2000000')
1101
+
1102
+ const state = await Actions.channel.getStates(Tempo.client, {
1103
+ channel: receipt1.channelId,
1104
+ })
1105
+ expect(state.deposit).toBe(2_000_000n)
1106
+ })
1107
+
1108
+ test('reuses persisted session state after server restart', async () => {
1109
+ const { namespace } = TestStore.durableObjectNamespace()
1110
+ const createStore = () => Store.durableObject(namespace)
1111
+ let app = createApp({
1112
+ auth: {
1113
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
1114
+ mpp: createMppServer({ store: createStore() }),
1115
+ rateLimit: overQuotaExceptMpp(),
1116
+ },
1117
+ policy: {
1118
+ mpp: { session: { amount: '1', suggestedDeposit: '2' } },
1119
+ public: { rateLimit: { perMinute: 1 } },
1120
+ },
1121
+ })
1122
+ const client = createMppClient({ fetch: (request) => app.fetch(request) })
1123
+
1124
+ const first = await client.fetch('http://tempo-api.test/protected')
1125
+ app = createApp({
1126
+ auth: {
1127
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
1128
+ mpp: createMppServer({ store: createStore() }),
1129
+ rateLimit: overQuotaExceptMpp(),
1130
+ },
1131
+ policy: {
1132
+ mpp: { session: { amount: '1', suggestedDeposit: '2' } },
1133
+ public: { rateLimit: { perMinute: 1 } },
1134
+ },
1135
+ })
1136
+ const second = await client.fetch('http://tempo-api.test/protected')
1137
+
1138
+ expect(first.status).toBe(200)
1139
+ expect(second.status).toBe(200)
1140
+ expect(sessionReceipt(second)).toMatchObject({
1141
+ channelId: sessionReceipt(first).channelId,
1142
+ spent: '2000000',
1143
+ })
1144
+ })
1145
+
1146
+ test('rejects a voucher that exceeds the channel deposit', async () => {
1147
+ const mpp = createMppServer()
1148
+ const app = createApp({
1149
+ auth: {
1150
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
1151
+ mpp,
1152
+ rateLimit: overQuotaExceptMpp(),
1153
+ },
1154
+ policy: {
1155
+ mpp: { session: { amount: '1' } },
1156
+ public: { rateLimit: { perMinute: 1 } },
1157
+ },
1158
+ })
1159
+ const client = createMppClient(app)
1160
+
1161
+ // The opening deposit covers one request; the second voucher overruns
1162
+ // it and the low-level client cannot top up, so the server re-challenges.
1163
+ const first = await client.fetch('http://tempo-api.test/protected')
1164
+ const second = await client.fetch('http://tempo-api.test/protected')
1165
+
1166
+ expect(first.status).toBe(200)
1167
+ expect(second.status).toBe(402)
1168
+ expect(second.headers.get('www-authenticate')?.startsWith('Payment ')).toBe(true)
1169
+ })
1170
+
1171
+ test('rejects a malformed payment credential with a fresh challenge', async () => {
1172
+ const mpp = createMppServer()
1173
+ const app = createApp({
1174
+ auth: {
1175
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
1176
+ mpp,
1177
+ rateLimit: underQuota(),
1178
+ },
1179
+ policy: {
1180
+ mpp: { session: { amount: '1' } },
1181
+ public: { rateLimit: { perMinute: 1 } },
1182
+ },
1183
+ })
1184
+ const response = await app.request('/protected', {
1185
+ headers: { authorization: 'Payment not-a-credential' },
1186
+ })
1187
+
1188
+ expect(response.status).toBe(402)
1189
+ expect(response.headers.get('www-authenticate')?.startsWith('Payment ')).toBe(true)
1190
+ })
833
1191
  })
834
1192
 
835
1193
  describe('rateLimit', () => {
@@ -1281,7 +1639,7 @@ describe('require', () => {
1281
1639
  },
1282
1640
  policy: {
1283
1641
  apiKey: { rateLimit: { perMinute: 1 }, scopes: ['data:read'] },
1284
- mpp: { charge: { amount: '1', description: 'Protected data' } },
1642
+ mpp: { session: { amount: '1', description: 'Protected data' } },
1285
1643
  },
1286
1644
  })
1287
1645
  const response = await app.request('/protected', {
@@ -1298,6 +1656,87 @@ describe('require', () => {
1298
1656
  })
1299
1657
  })
1300
1658
 
1659
+ describe('ensureProject', () => {
1660
+ test('authorizes a flat project route against the API key organization', async () => {
1661
+ const db = TestApp.database()
1662
+ const organization = await Organizations.create(db, { id: key.orgId, name: 'Test org' })
1663
+ const project = await Projects.create(db, { name: 'Test project', orgId: organization.id })
1664
+ const response = await createProjectGuardApp(db, apiKeyPrincipal(organization.id)).request(
1665
+ `/v1/projects/${project.id}/resource`,
1666
+ )
1667
+
1668
+ expect(response.status).toMatchInlineSnapshot(`200`)
1669
+ expect(await response.json()).toEqual({
1670
+ membership: null,
1671
+ orgId: organization.id,
1672
+ projectId: project.id,
1673
+ })
1674
+ })
1675
+
1676
+ test('hides a flat project from an API key in another organization', async () => {
1677
+ const db = TestApp.database()
1678
+ const organization = await Organizations.create(db, { id: key.orgId, name: 'Test org' })
1679
+ const project = await Projects.create(db, { name: 'Test project', orgId: organization.id })
1680
+ const response = await createProjectGuardApp(db, apiKeyPrincipal('org_foreign')).request(
1681
+ `/v1/projects/${project.id}/resource`,
1682
+ )
1683
+
1684
+ expect(response.status).toMatchInlineSnapshot(`404`)
1685
+ expect(await response.json()).toMatchInlineSnapshot(`
1686
+ {
1687
+ "error": {
1688
+ "code": "project_not_found",
1689
+ "message": "Project not found",
1690
+ },
1691
+ }
1692
+ `)
1693
+ })
1694
+
1695
+ test('resolves session membership for a flat project route', async () => {
1696
+ const db = TestApp.database()
1697
+ const user = await Users.upsertByAddress(db, {
1698
+ address: '0x0000000000000000000000000000000000000001',
1699
+ })
1700
+ const organization = await Organizations.create(db, { name: 'Test org' })
1701
+ await Memberships.create(db, { orgId: organization.id, role: 'member', userId: user.id })
1702
+ const project = await Projects.create(db, { name: 'Test project', orgId: organization.id })
1703
+ const response = await createProjectGuardApp(db, sessionPrincipal(user.id)).request(
1704
+ `/v1/projects/${project.id}/resource`,
1705
+ )
1706
+ const body = (await response.json()) as Record<string, unknown>
1707
+
1708
+ expect(response.status).toMatchInlineSnapshot(`200`)
1709
+ expect(body).toEqual({
1710
+ membership: 'member',
1711
+ orgId: organization.id,
1712
+ projectId: project.id,
1713
+ })
1714
+ })
1715
+
1716
+ test('preserves nested organization role authorization', async () => {
1717
+ const db = TestApp.database()
1718
+ const user = await Users.upsertByAddress(db, {
1719
+ address: '0x0000000000000000000000000000000000000001',
1720
+ })
1721
+ const organization = await Organizations.create(db, { name: 'Test org' })
1722
+ await Memberships.create(db, { orgId: organization.id, role: 'member', userId: user.id })
1723
+ const project = await Projects.create(db, { name: 'Test project', orgId: organization.id })
1724
+ const response = await createNestedProjectGuardApp(db, sessionPrincipal(user.id)).request(
1725
+ `/v1/orgs/${organization.id}/projects/${project.id}/resource`,
1726
+ )
1727
+
1728
+ expect(response.status).toMatchInlineSnapshot(`403`)
1729
+ expect(await response.json()).toMatchInlineSnapshot(`
1730
+ {
1731
+ "error": {
1732
+ "code": "forbidden",
1733
+ "message": "Requires the admin role",
1734
+ },
1735
+ }
1736
+ `)
1737
+ })
1738
+ })
1739
+
1301
1740
  function createScopedApp(options: {
1302
1741
  auth?: Auth.Context | undefined
1303
1742
  routes: readonly { path: string; policy: Auth.require.Policy }[]
@@ -1322,6 +1761,67 @@ function createScopedApp(options: {
1322
1761
  return app
1323
1762
  }
1324
1763
 
1764
+ type GuardEnvironment = {
1765
+ Variables: Auth.Variables & { db: Db.Source }
1766
+ }
1767
+
1768
+ function createProjectGuardApp(db: Db.Source, principal: Auth.Principal) {
1769
+ const app = new Hono<GuardEnvironment>()
1770
+ app.use('*', async (c, next) => {
1771
+ c.set('db', db)
1772
+ c.set('principal', principal)
1773
+ await next()
1774
+ })
1775
+ app.get('/v1/projects/:projectId/resource', Auth.ensureProject(), projectGuardHandler)
1776
+ return app
1777
+ }
1778
+
1779
+ function createNestedProjectGuardApp(db: Db.Source, principal: Auth.Principal) {
1780
+ const app = new Hono<GuardEnvironment>()
1781
+ app.use('*', async (c, next) => {
1782
+ c.set('db', db)
1783
+ c.set('principal', principal)
1784
+ await next()
1785
+ })
1786
+ app.get(
1787
+ '/v1/orgs/:orgId/projects/:projectId/resource',
1788
+ Auth.ensureOrg({ role: 'admin' }),
1789
+ Auth.ensureProject(),
1790
+ projectGuardHandler,
1791
+ )
1792
+ return app
1793
+ }
1794
+
1795
+ function projectGuardHandler(c: Context<GuardEnvironment>) {
1796
+ return c.json({
1797
+ membership: Auth.membership(c)?.role ?? null,
1798
+ orgId: Auth.org(c).id,
1799
+ projectId: Auth.project(c).id,
1800
+ })
1801
+ }
1802
+
1803
+ function apiKeyPrincipal(orgId: string): Auth.Principal {
1804
+ const apiKey = { ...key, orgId }
1805
+ return {
1806
+ apiKey,
1807
+ environment: apiKey.environment,
1808
+ id: apiKey.id,
1809
+ orgId,
1810
+ type: 'api_key',
1811
+ }
1812
+ }
1813
+
1814
+ function sessionPrincipal(id: string): Auth.Principal {
1815
+ return {
1816
+ id,
1817
+ identity: {
1818
+ provider: 'wallet',
1819
+ subject: '0x0000000000000000000000000000000000000001',
1820
+ },
1821
+ type: 'session',
1822
+ }
1823
+ }
1824
+
1325
1825
  type Options = {
1326
1826
  auth?: Auth.Context | undefined
1327
1827
  logger?: Log.Emit | undefined
@@ -1349,15 +1849,18 @@ function createApp(options: Options = {}) {
1349
1849
  return app
1350
1850
  }
1351
1851
 
1352
- function createMppServer() {
1852
+ function createMppServer(options: createMppServer.Options = {}) {
1353
1853
  return Mppx.create({
1354
1854
  methods: [
1355
- tempo.charge({
1855
+ tempo.session({
1856
+ bootstrap: false,
1356
1857
  chainId: Tempo.chain.id,
1357
1858
  currency: Tempo.currency,
1358
1859
  decimals: 6,
1359
1860
  getClient: () => Tempo.client,
1360
1861
  recipient: Tempo.accounts[2].address,
1862
+ ...options,
1863
+ unitType: 'request',
1361
1864
  }),
1362
1865
  ],
1363
1866
  realm: 'tempo-api-test',
@@ -1365,6 +1868,34 @@ function createMppServer() {
1365
1868
  })
1366
1869
  }
1367
1870
 
1871
+ declare namespace createMppServer {
1872
+ type Options = Pick<NonNullable<Parameters<typeof tempo.session>[0]>, 'store'>
1873
+ }
1874
+
1875
+ function createMppHandler(
1876
+ options: createMppHandler.Options = {},
1877
+ ): NonNullable<Auth.Context['mpp']> {
1878
+ return {
1879
+ ...(options.rateLimit ? { rateLimit: options.rateLimit } : {}),
1880
+ session: () => async (_c, next) => {
1881
+ options.onPayment?.()
1882
+ await next()
1883
+ },
1884
+ }
1885
+ }
1886
+
1887
+ declare namespace createMppHandler {
1888
+ type Options = {
1889
+ onPayment?: (() => void) | undefined
1890
+ rateLimit?: RateLimit.Limit | undefined
1891
+ }
1892
+ }
1893
+
1894
+ /** Reads the session receipt attached to a paid response. */
1895
+ function sessionReceipt(response: Response) {
1896
+ return Receipt.fromResponse(response) as Session.Precompile.Protocol.SessionReceipt
1897
+ }
1898
+
1368
1899
  function overQuota(): RateLimit.Store {
1369
1900
  return {
1370
1901
  async consume(options) {
@@ -1378,12 +1909,29 @@ function overQuota(): RateLimit.Store {
1378
1909
  }
1379
1910
  }
1380
1911
 
1381
- function overQuotaOnce(): RateLimit.Store {
1382
- let consumed = false
1912
+ function underQuota(): RateLimit.Store {
1913
+ return {
1914
+ async consume(options) {
1915
+ return {
1916
+ allowed: true,
1917
+ limit: options.limit.perMinute,
1918
+ remaining: Math.max(options.limit.perMinute - 1, 0),
1919
+ reset: Math.ceil(Date.now() / 1_000) + 60,
1920
+ }
1921
+ },
1922
+ }
1923
+ }
1924
+
1925
+ function overQuotaExceptMpp(): RateLimit.Store {
1383
1926
  return {
1384
1927
  async consume(options) {
1385
- if (consumed) throw new Error('rate limit should not be consumed after payment')
1386
- consumed = true
1928
+ if (options.key.startsWith('mpp:'))
1929
+ return {
1930
+ allowed: true,
1931
+ limit: options.limit.perMinute,
1932
+ remaining: Math.max(options.limit.perMinute - 1, 0),
1933
+ reset: Math.ceil(Date.now() / 1_000) + 60,
1934
+ }
1387
1935
  return {
1388
1936
  allowed: false,
1389
1937
  limit: options.limit.perMinute,