tapimo 0.4.3 → 0.5.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 (192) hide show
  1. package/dist/App.d.ts +7222 -6316
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js +4 -0
  4. package/dist/App.js.map +1 -1
  5. package/dist/Scope.d.ts +18 -1
  6. package/dist/Scope.d.ts.map +1 -1
  7. package/dist/Scope.js +24 -3
  8. package/dist/Scope.js.map +1 -1
  9. package/dist/admin/apps/verified-tokens.d.ts +116 -116
  10. package/dist/apps/data/App.d.ts +2541 -2607
  11. package/dist/apps/data/App.d.ts.map +1 -1
  12. package/dist/apps/data/App.js +135 -44
  13. package/dist/apps/data/App.js.map +1 -1
  14. package/dist/apps/data/FundingQuoteProviders.d.ts +36 -0
  15. package/dist/apps/data/FundingQuoteProviders.d.ts.map +1 -0
  16. package/dist/apps/data/FundingQuoteProviders.js +190 -0
  17. package/dist/apps/data/FundingQuoteProviders.js.map +1 -0
  18. package/dist/apps/data/index.d.ts +1 -1
  19. package/dist/apps/data/index.js +1 -1
  20. package/dist/apps/data/routes/activities.d.ts +242 -242
  21. package/dist/apps/data/routes/addresses.d.ts +227 -227
  22. package/dist/apps/data/routes/balances.d.ts +12 -12
  23. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  24. package/dist/apps/data/routes/balances.js +67 -149
  25. package/dist/apps/data/routes/balances.js.map +1 -1
  26. package/dist/apps/data/routes/blocks.d.ts +125 -126
  27. package/dist/apps/data/routes/blocks.d.ts.map +1 -1
  28. package/dist/apps/data/routes/blocks.js +3 -5
  29. package/dist/apps/data/routes/blocks.js.map +1 -1
  30. package/dist/apps/data/routes/coingecko.d.ts +55 -54
  31. package/dist/apps/data/routes/coingecko.d.ts.map +1 -1
  32. package/dist/apps/data/routes/coingecko.js +3 -8
  33. package/dist/apps/data/routes/coingecko.js.map +1 -1
  34. package/dist/apps/data/routes/exchanges.d.ts +455 -455
  35. package/dist/apps/data/routes/fee-amm.d.ts +92 -92
  36. package/dist/apps/data/routes/funding.d.ts +91 -864
  37. package/dist/apps/data/routes/funding.d.ts.map +1 -1
  38. package/dist/apps/data/routes/funding.js +138 -450
  39. package/dist/apps/data/routes/funding.js.map +1 -1
  40. package/dist/apps/data/routes/rpc.d.ts.map +1 -1
  41. package/dist/apps/data/routes/rpc.js +86 -16
  42. package/dist/apps/data/routes/rpc.js.map +1 -1
  43. package/dist/apps/data/routes/tokenlist.d.ts +36 -36
  44. package/dist/apps/data/routes/webhooks.d.ts +340 -0
  45. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  46. package/dist/apps/data/routes/webhooks.js +13 -1
  47. package/dist/apps/data/routes/webhooks.js.map +1 -1
  48. package/dist/apps/index.d.ts +1 -1
  49. package/dist/apps/index.js +1 -1
  50. package/dist/apps/management/App.d.ts +1843 -861
  51. package/dist/apps/management/App.d.ts.map +1 -1
  52. package/dist/apps/management/App.js +4 -0
  53. package/dist/apps/management/App.js.map +1 -1
  54. package/dist/apps/management/routes/api-keys.d.ts +121 -121
  55. package/dist/apps/management/routes/api-keys.js +2 -2
  56. package/dist/apps/management/routes/api-keys.js.map +1 -1
  57. package/dist/apps/management/routes/billing.d.ts +2 -2
  58. package/dist/apps/management/routes/invite-links.d.ts +1100 -0
  59. package/dist/apps/management/routes/invite-links.d.ts.map +1 -0
  60. package/dist/apps/management/routes/invite-links.js +411 -0
  61. package/dist/apps/management/routes/invite-links.js.map +1 -0
  62. package/dist/apps/management/routes/me.d.ts +13 -13
  63. package/dist/apps/management/routes/members.d.ts +77 -77
  64. package/dist/apps/management/routes/orgs.d.ts +96 -96
  65. package/dist/apps/management/routes/projects.d.ts +62 -62
  66. package/dist/apps/management/routes/scopes.d.ts +11 -11
  67. package/dist/apps/management/routes/scopes.d.ts.map +1 -1
  68. package/dist/apps/management/routes/scopes.js +2 -2
  69. package/dist/apps/management/routes/scopes.js.map +1 -1
  70. package/dist/apps/management/routes/usage.d.ts +79 -79
  71. package/dist/apps/mcp.d.ts.map +1 -1
  72. package/dist/apps/mcp.js +3 -1
  73. package/dist/apps/mcp.js.map +1 -1
  74. package/dist/apps/relay/App.d.ts.map +1 -1
  75. package/dist/apps/relay/App.js +42 -4
  76. package/dist/apps/relay/App.js.map +1 -1
  77. package/dist/cloudflare.d.ts +2 -0
  78. package/dist/cloudflare.d.ts.map +1 -1
  79. package/dist/cloudflare.js +4 -0
  80. package/dist/cloudflare.js.map +1 -1
  81. package/dist/db/Db.d.ts +3 -0
  82. package/dist/db/Db.d.ts.map +1 -1
  83. package/dist/db/Db.js +57 -0
  84. package/dist/db/Db.js.map +1 -1
  85. package/dist/db/Schema.d.ts +31 -0
  86. package/dist/db/Schema.d.ts.map +1 -1
  87. package/dist/db/Schema.js +31 -0
  88. package/dist/db/Schema.js.map +1 -1
  89. package/dist/db/tables/inviteLinks.d.ts +42 -0
  90. package/dist/db/tables/inviteLinks.d.ts.map +1 -0
  91. package/dist/db/tables/inviteLinks.js +148 -0
  92. package/dist/db/tables/inviteLinks.js.map +1 -0
  93. package/dist/db/tables/organizations.d.ts.map +1 -1
  94. package/dist/db/tables/organizations.js +2 -0
  95. package/dist/db/tables/organizations.js.map +1 -1
  96. package/dist/handlers/relay.d.ts.map +1 -1
  97. package/dist/handlers/relay.js +13 -3
  98. package/dist/handlers/relay.js.map +1 -1
  99. package/dist/internal/Auth.d.ts +42 -23
  100. package/dist/internal/Auth.d.ts.map +1 -1
  101. package/dist/internal/Auth.js +169 -20
  102. package/dist/internal/Auth.js.map +1 -1
  103. package/dist/internal/EdgeCache.d.ts +3 -1
  104. package/dist/internal/EdgeCache.d.ts.map +1 -1
  105. package/dist/internal/EdgeCache.js +17 -2
  106. package/dist/internal/EdgeCache.js.map +1 -1
  107. package/dist/internal/Errors.d.ts +6 -0
  108. package/dist/internal/Errors.d.ts.map +1 -0
  109. package/dist/internal/Errors.js +16 -0
  110. package/dist/internal/Errors.js.map +1 -0
  111. package/dist/internal/FundingQuotes.d.ts +644 -0
  112. package/dist/internal/FundingQuotes.d.ts.map +1 -0
  113. package/dist/internal/{FundingRoutes.js → FundingQuotes.js} +93 -289
  114. package/dist/internal/FundingQuotes.js.map +1 -0
  115. package/dist/internal/Store.d.ts +61 -16
  116. package/dist/internal/Store.d.ts.map +1 -1
  117. package/dist/internal/Store.js +72 -17
  118. package/dist/internal/Store.js.map +1 -1
  119. package/dist/internal/Viem.d.ts +2 -0
  120. package/dist/internal/Viem.d.ts.map +1 -1
  121. package/dist/internal/Viem.js +11 -2
  122. package/dist/internal/Viem.js.map +1 -1
  123. package/dist/internal/index.d.ts +27 -0
  124. package/dist/internal/index.d.ts.map +1 -0
  125. package/dist/internal/index.js +27 -0
  126. package/dist/internal/index.js.map +1 -0
  127. package/package.json +8 -3
  128. package/src/ApiKeys.test.ts +23 -0
  129. package/src/App.test-d.ts +2 -0
  130. package/src/App.test.ts +60 -4
  131. package/src/App.ts +6 -0
  132. package/src/Client.test-d.ts +16 -54
  133. package/src/Scope.test.ts +1 -0
  134. package/src/Scope.ts +30 -4
  135. package/src/apps/data/App.test.ts +354 -0
  136. package/src/apps/data/App.ts +159 -52
  137. package/src/apps/data/FundingQuoteProviders.test.ts +151 -0
  138. package/src/apps/data/FundingQuoteProviders.ts +251 -0
  139. package/src/apps/data/index.ts +1 -1
  140. package/src/apps/data/routes/balances.test.ts +64 -18
  141. package/src/apps/data/routes/balances.ts +76 -177
  142. package/src/apps/data/routes/blocks.ts +3 -5
  143. package/src/apps/data/routes/coingecko.ts +3 -8
  144. package/src/apps/data/routes/funding.test.ts +149 -306
  145. package/src/apps/data/routes/funding.ts +154 -604
  146. package/src/apps/data/routes/indexer.test.ts +99 -3
  147. package/src/apps/data/routes/rpc.test.ts +219 -0
  148. package/src/apps/data/routes/rpc.ts +92 -17
  149. package/src/apps/data/routes/webhooks.test.ts +23 -2
  150. package/src/apps/data/routes/webhooks.ts +13 -1
  151. package/src/apps/index.ts +1 -1
  152. package/src/apps/management/App.ts +4 -0
  153. package/src/apps/management/routes/api-keys.test.ts +47 -3
  154. package/src/apps/management/routes/api-keys.ts +2 -2
  155. package/src/apps/management/routes/invite-links.test.ts +385 -0
  156. package/src/apps/management/routes/invite-links.ts +570 -0
  157. package/src/apps/management/routes/scopes.test.ts +3 -2
  158. package/src/apps/management/routes/scopes.ts +4 -2
  159. package/src/apps/mcp.test.ts +55 -14
  160. package/src/apps/mcp.ts +3 -1
  161. package/src/apps/relay/App.test.ts +324 -4
  162. package/src/apps/relay/App.ts +40 -4
  163. package/src/cloudflare.ts +5 -0
  164. package/src/db/Db.ts +65 -0
  165. package/src/db/Schema.ts +39 -0
  166. package/src/db/tables/inviteLinks.ts +187 -0
  167. package/src/db/tables/organizations.ts +2 -0
  168. package/src/handlers/relay.ts +16 -4
  169. package/src/internal/Auth.test.ts +528 -37
  170. package/src/internal/Auth.ts +246 -45
  171. package/src/internal/EdgeCache.test.ts +24 -3
  172. package/src/internal/EdgeCache.ts +18 -2
  173. package/src/internal/FundingQuotes.test.ts +174 -0
  174. package/src/internal/FundingQuotes.ts +735 -0
  175. package/src/internal/Log.test.ts +4 -4
  176. package/src/internal/Store.test-d.ts +15 -0
  177. package/src/internal/Store.test.ts +153 -2
  178. package/src/internal/Store.ts +157 -41
  179. package/src/internal/Viem.ts +14 -5
  180. package/src/internal/index.test.ts +34 -0
  181. package/src/internal/index.ts +26 -0
  182. package/dist/apps/data/FundingRouteProviders.d.ts +0 -143
  183. package/dist/apps/data/FundingRouteProviders.d.ts.map +0 -1
  184. package/dist/apps/data/FundingRouteProviders.js +0 -546
  185. package/dist/apps/data/FundingRouteProviders.js.map +0 -1
  186. package/dist/internal/FundingRoutes.d.ts +0 -1085
  187. package/dist/internal/FundingRoutes.d.ts.map +0 -1
  188. package/dist/internal/FundingRoutes.js.map +0 -1
  189. package/src/apps/data/FundingRouteProviders.test.ts +0 -485
  190. package/src/apps/data/FundingRouteProviders.ts +0 -744
  191. package/src/internal/FundingRoutes.test.ts +0 -239
  192. package/src/internal/FundingRoutes.ts +0 -1067
@@ -1,7 +1,10 @@
1
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'
6
9
  import type * as Db from '../db/Db.js'
7
10
  import * as Memberships from '../db/tables/memberships.js'
@@ -14,6 +17,7 @@ import * as Scope from '../Scope.js'
14
17
  import * as Viem from './Viem.js'
15
18
  import * as TestApp from '../../test/App.js'
16
19
  import { createClient as createMppClient } from '../../test/Mppx.js'
20
+ import * as TestStore from '../../test/Store.js'
17
21
  import * as Tempo from '../../test/Tempo.js'
18
22
 
19
23
  const key = {
@@ -559,7 +563,7 @@ describe('require', () => {
559
563
  const policy: Auth.require.Policy = {
560
564
  apiKey: { scopes: [Scope.wildcard] },
561
565
  ...(options.public === false ? {} : { public: { rateLimit: { perMinute: 60 } } }),
562
- ...(options.mpp ? { mpp: { charge: { amount: '1' } } } : {}),
566
+ ...(options.mpp ? { mpp: { session: { amount: '1' } } } : {}),
563
567
  }
564
568
  const app = new Hono<Auth.Environment>()
565
569
  app.use('*', async (c, next) => {
@@ -739,6 +743,121 @@ describe('require', () => {
739
743
  })
740
744
 
741
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
+
742
861
  test('returns payment challenge for public over-quota requests', async () => {
743
862
  const mpp = createMppServer()
744
863
  const app = createApp({
@@ -748,7 +867,7 @@ describe('require', () => {
748
867
  rateLimit: overQuota(),
749
868
  },
750
869
  policy: {
751
- mpp: { charge: { amount: '1', description: 'Protected data' } },
870
+ mpp: { session: { amount: '1', description: 'Protected data' } },
752
871
  public: { rateLimit: { perMinute: 1 } },
753
872
  },
754
873
  })
@@ -760,16 +879,16 @@ describe('require', () => {
760
879
  ).toMatchInlineSnapshot(`true`)
761
880
  })
762
881
 
763
- test('accepts paid public retry without consuming quota', async () => {
882
+ test('accepts paid public retry without consuming public quota', async () => {
764
883
  const mpp = createMppServer()
765
884
  const app = createApp({
766
885
  auth: {
767
886
  apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
768
887
  mpp,
769
- rateLimit: overQuotaOnce(),
888
+ rateLimit: overQuotaExceptMpp(),
770
889
  },
771
890
  policy: {
772
- mpp: { charge: { amount: '1', description: 'Protected data' } },
891
+ mpp: { session: { amount: '1', description: 'Protected data' } },
773
892
  public: { rateLimit: { perMinute: 1 } },
774
893
  },
775
894
  })
@@ -804,7 +923,7 @@ describe('require', () => {
804
923
  rateLimit: { perMinute: 1 },
805
924
  scopes: ['data:read'],
806
925
  },
807
- mpp: { charge: { amount: '1', description: 'Protected data' } },
926
+ mpp: { session: { amount: '1', description: 'Protected data' } },
808
927
  },
809
928
  })
810
929
  const response = await app.request('/protected', {
@@ -823,14 +942,14 @@ describe('require', () => {
823
942
  auth: {
824
943
  apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
825
944
  mpp,
826
- rateLimit: overQuotaOnce(),
945
+ rateLimit: overQuotaExceptMpp(),
827
946
  },
828
947
  policy: {
829
948
  apiKey: {
830
949
  rateLimit: { perMinute: 1 },
831
950
  scopes: ['data:read'],
832
951
  },
833
- mpp: { charge: { amount: '1', description: 'Protected data' } },
952
+ mpp: { session: { amount: '1', description: 'Protected data' } },
834
953
  },
835
954
  })
836
955
  const response = await createMppClient(app).fetch('http://tempo-api.test/protected', {
@@ -855,6 +974,252 @@ describe('require', () => {
855
974
  }
856
975
  `)
857
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('session challenge uses the explicit request chain', async () => {
1014
+ const mpp = createMppServer({
1015
+ chainId: Viem.chainId.mainnet,
1016
+ getClient: ({ chainId = Viem.chainId.mainnet }) => Viem.getClient({ chainId }),
1017
+ })
1018
+ const app = createApp({
1019
+ auth: {
1020
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
1021
+ mpp,
1022
+ rateLimit: overQuota(),
1023
+ },
1024
+ policy: {
1025
+ mpp: { session: { amount: '1' } },
1026
+ public: { rateLimit: { perMinute: 1 } },
1027
+ },
1028
+ })
1029
+
1030
+ const defaultResponse = await app.request('/protected')
1031
+ const testnetResponse = await app.request('/protected?chainId=testnet')
1032
+
1033
+ expect(Challenge.fromHeaders(defaultResponse.headers).request).toMatchObject({
1034
+ methodDetails: {
1035
+ chainId: Viem.chainId.mainnet,
1036
+ },
1037
+ })
1038
+ expect(Challenge.fromHeaders(testnetResponse.headers).request).toMatchObject({
1039
+ methodDetails: {
1040
+ chainId: Viem.chainId.testnet,
1041
+ },
1042
+ })
1043
+ })
1044
+
1045
+ test('route session overrides propagate to the challenge', async () => {
1046
+ const mpp = createMppServer()
1047
+ const app = createApp({
1048
+ auth: {
1049
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
1050
+ mpp,
1051
+ rateLimit: overQuota(),
1052
+ },
1053
+ policy: {
1054
+ mpp: { session: { amount: '2.5', suggestedDeposit: '10' } },
1055
+ public: { rateLimit: { perMinute: 1 } },
1056
+ },
1057
+ })
1058
+ const response = await app.request('/protected')
1059
+ const challenge = Challenge.fromHeaders(response.headers)
1060
+
1061
+ expect(response.status).toBe(402)
1062
+ expect(challenge.request).toMatchObject({
1063
+ // Wire amounts are raw token units at 6 decimals.
1064
+ amount: '2500000',
1065
+ suggestedDeposit: '10000000',
1066
+ })
1067
+ })
1068
+
1069
+ test('paid retry opens a funded on-chain channel', async () => {
1070
+ const mpp = createMppServer()
1071
+ const app = createApp({
1072
+ auth: {
1073
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
1074
+ mpp,
1075
+ rateLimit: overQuotaExceptMpp(),
1076
+ },
1077
+ policy: {
1078
+ mpp: { session: { amount: '1', description: 'Protected data' } },
1079
+ public: { rateLimit: { perMinute: 1 } },
1080
+ },
1081
+ })
1082
+ const response = await createMppClient(app).fetch('http://tempo-api.test/protected')
1083
+
1084
+ expect(response.status).toBe(200)
1085
+ const receipt = sessionReceipt(response)
1086
+ expect(receipt.method).toBe('tempo')
1087
+ expect(receipt.intent).toBe('session')
1088
+ expect(receipt.status).toBe('success')
1089
+ expect(receipt.channelId).toMatch(/^0x[0-9a-fA-F]{64}$/)
1090
+ // One paid request at amount '1' with 6 decimals.
1091
+ expect(receipt.spent).toBe('1000000')
1092
+ expect(BigInt(receipt.acceptedCumulative)).toBeGreaterThanOrEqual(1_000_000n)
1093
+
1094
+ const state = await Actions.channel.getStates(Tempo.client, {
1095
+ channel: receipt.channelId,
1096
+ })
1097
+ // The client deposits the request amount when no deposit is suggested.
1098
+ expect(state.deposit).toBe(1_000_000n)
1099
+ // No settlement schedule configured, so nothing settles inline.
1100
+ expect(state.settled).toBe(0n)
1101
+ expect(state.closeRequestedAt).toBe(0)
1102
+ })
1103
+
1104
+ test('reuses the session channel across paid requests', async () => {
1105
+ const mpp = createMppServer()
1106
+ const app = createApp({
1107
+ auth: {
1108
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
1109
+ mpp,
1110
+ rateLimit: overQuotaExceptMpp(),
1111
+ },
1112
+ policy: {
1113
+ // The suggested deposit pre-funds the channel for both requests.
1114
+ mpp: { session: { amount: '1', suggestedDeposit: '2' } },
1115
+ public: { rateLimit: { perMinute: 1 } },
1116
+ },
1117
+ })
1118
+ const client = createMppClient(app)
1119
+
1120
+ const first = await client.fetch('http://tempo-api.test/protected')
1121
+ const second = await client.fetch('http://tempo-api.test/protected')
1122
+
1123
+ expect(first.status).toBe(200)
1124
+ expect(second.status).toBe(200)
1125
+
1126
+ const receipt1 = sessionReceipt(first)
1127
+ const receipt2 = sessionReceipt(second)
1128
+ expect(receipt2.channelId).toBe(receipt1.channelId)
1129
+ expect(BigInt(receipt2.acceptedCumulative)).toBeGreaterThan(
1130
+ BigInt(receipt1.acceptedCumulative),
1131
+ )
1132
+ expect(receipt2.spent).toBe('2000000')
1133
+
1134
+ const state = await Actions.channel.getStates(Tempo.client, {
1135
+ channel: receipt1.channelId,
1136
+ })
1137
+ expect(state.deposit).toBe(2_000_000n)
1138
+ })
1139
+
1140
+ test('reuses persisted session state after server restart', async () => {
1141
+ const { namespace } = TestStore.durableObjectNamespace()
1142
+ const createStore = () => Store.durableObject(namespace)
1143
+ let app = createApp({
1144
+ auth: {
1145
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
1146
+ mpp: createMppServer({ store: createStore() }),
1147
+ rateLimit: overQuotaExceptMpp(),
1148
+ },
1149
+ policy: {
1150
+ mpp: { session: { amount: '1', suggestedDeposit: '2' } },
1151
+ public: { rateLimit: { perMinute: 1 } },
1152
+ },
1153
+ })
1154
+ const client = createMppClient({ fetch: (request) => app.fetch(request) })
1155
+
1156
+ const first = await client.fetch('http://tempo-api.test/protected')
1157
+ app = createApp({
1158
+ auth: {
1159
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
1160
+ mpp: createMppServer({ store: createStore() }),
1161
+ rateLimit: overQuotaExceptMpp(),
1162
+ },
1163
+ policy: {
1164
+ mpp: { session: { amount: '1', suggestedDeposit: '2' } },
1165
+ public: { rateLimit: { perMinute: 1 } },
1166
+ },
1167
+ })
1168
+ const second = await client.fetch('http://tempo-api.test/protected')
1169
+
1170
+ expect(first.status).toBe(200)
1171
+ expect(second.status).toBe(200)
1172
+ expect(sessionReceipt(second)).toMatchObject({
1173
+ channelId: sessionReceipt(first).channelId,
1174
+ spent: '2000000',
1175
+ })
1176
+ })
1177
+
1178
+ test('rejects a voucher that exceeds the channel deposit', async () => {
1179
+ const mpp = createMppServer()
1180
+ const app = createApp({
1181
+ auth: {
1182
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
1183
+ mpp,
1184
+ rateLimit: overQuotaExceptMpp(),
1185
+ },
1186
+ policy: {
1187
+ mpp: { session: { amount: '1' } },
1188
+ public: { rateLimit: { perMinute: 1 } },
1189
+ },
1190
+ })
1191
+ const client = createMppClient(app)
1192
+
1193
+ // The opening deposit covers one request; the second voucher overruns
1194
+ // it and the low-level client cannot top up, so the server re-challenges.
1195
+ const first = await client.fetch('http://tempo-api.test/protected')
1196
+ const second = await client.fetch('http://tempo-api.test/protected')
1197
+
1198
+ expect(first.status).toBe(200)
1199
+ expect(second.status).toBe(402)
1200
+ expect(second.headers.get('www-authenticate')?.startsWith('Payment ')).toBe(true)
1201
+ })
1202
+
1203
+ test('rejects a malformed payment credential with a fresh challenge', async () => {
1204
+ const mpp = createMppServer()
1205
+ const app = createApp({
1206
+ auth: {
1207
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
1208
+ mpp,
1209
+ rateLimit: underQuota(),
1210
+ },
1211
+ policy: {
1212
+ mpp: { session: { amount: '1' } },
1213
+ public: { rateLimit: { perMinute: 1 } },
1214
+ },
1215
+ })
1216
+ const response = await app.request('/protected', {
1217
+ headers: { authorization: 'Payment not-a-credential' },
1218
+ })
1219
+
1220
+ expect(response.status).toBe(402)
1221
+ expect(response.headers.get('www-authenticate')?.startsWith('Payment ')).toBe(true)
1222
+ })
858
1223
  })
859
1224
 
860
1225
  describe('rateLimit', () => {
@@ -1306,7 +1671,7 @@ describe('require', () => {
1306
1671
  },
1307
1672
  policy: {
1308
1673
  apiKey: { rateLimit: { perMinute: 1 }, scopes: ['data:read'] },
1309
- mpp: { charge: { amount: '1', description: 'Protected data' } },
1674
+ mpp: { session: { amount: '1', description: 'Protected data' } },
1310
1675
  },
1311
1676
  })
1312
1677
  const response = await app.request('/protected', {
@@ -1324,29 +1689,74 @@ describe('require', () => {
1324
1689
  })
1325
1690
 
1326
1691
  describe('ensureProject', () => {
1327
- test('authorizes a flat project route against the API key organization', async () => {
1692
+ test('authorizes an organization-attributed API key', async () => {
1328
1693
  const db = TestApp.database()
1329
1694
  const organization = await Organizations.create(db, { id: key.orgId, name: 'Test org' })
1330
1695
  const project = await Projects.create(db, { name: 'Test project', orgId: organization.id })
1331
- const response = await createProjectGuardApp(db, apiKeyPrincipal(organization.id)).request(
1332
- `/v1/projects/${project.id}/resource`,
1333
- )
1696
+ const response = await createProjectGuardApp(
1697
+ db,
1698
+ apiKeyPrincipal({ orgId: organization.id }),
1699
+ ).request(`/v1/projects/${project.id}/resource`)
1700
+ const { orgId, projectId, ...body } = await response.json()
1334
1701
 
1335
1702
  expect(response.status).toMatchInlineSnapshot(`200`)
1336
- expect(await response.json()).toEqual({
1337
- membership: null,
1338
- orgId: organization.id,
1339
- projectId: project.id,
1340
- })
1703
+ expect(orgId).toBe(organization.id)
1704
+ expect(projectId).toBe(project.id)
1705
+ expect(body).toMatchInlineSnapshot(`
1706
+ {
1707
+ "membership": null,
1708
+ }
1709
+ `)
1710
+ })
1711
+
1712
+ test('authorizes a project-attributed API key for its project', async () => {
1713
+ const db = TestApp.database()
1714
+ const organization = await Organizations.create(db, { id: key.orgId, name: 'Test org' })
1715
+ const project = await Projects.create(db, { name: 'Test project', orgId: organization.id })
1716
+ const response = await createProjectGuardApp(
1717
+ db,
1718
+ apiKeyPrincipal({ orgId: organization.id, projectId: project.id }),
1719
+ ).request(`/v1/projects/${project.id}/resource`)
1720
+ const { orgId, projectId, ...body } = await response.json()
1721
+
1722
+ expect(response.status).toMatchInlineSnapshot(`200`)
1723
+ expect(orgId).toBe(organization.id)
1724
+ expect(projectId).toBe(project.id)
1725
+ expect(body).toMatchInlineSnapshot(`
1726
+ {
1727
+ "membership": null,
1728
+ }
1729
+ `)
1730
+ })
1731
+
1732
+ test('hides another project from a project-attributed API key', async () => {
1733
+ const db = TestApp.database()
1734
+ const organization = await Organizations.create(db, { id: key.orgId, name: 'Test org' })
1735
+ const project = await Projects.create(db, { name: 'Test project', orgId: organization.id })
1736
+ const response = await createProjectGuardApp(
1737
+ db,
1738
+ apiKeyPrincipal({ orgId: organization.id, projectId: 'prj_other' }),
1739
+ ).request(`/v1/projects/${project.id}/resource`)
1740
+
1741
+ expect(response.status).toMatchInlineSnapshot(`404`)
1742
+ expect(await response.json()).toMatchInlineSnapshot(`
1743
+ {
1744
+ "error": {
1745
+ "code": "project_not_found",
1746
+ "message": "Project not found",
1747
+ },
1748
+ }
1749
+ `)
1341
1750
  })
1342
1751
 
1343
1752
  test('hides a flat project from an API key in another organization', async () => {
1344
1753
  const db = TestApp.database()
1345
1754
  const organization = await Organizations.create(db, { id: key.orgId, name: 'Test org' })
1346
1755
  const project = await Projects.create(db, { name: 'Test project', orgId: organization.id })
1347
- const response = await createProjectGuardApp(db, apiKeyPrincipal('org_foreign')).request(
1348
- `/v1/projects/${project.id}/resource`,
1349
- )
1756
+ const response = await createProjectGuardApp(
1757
+ db,
1758
+ apiKeyPrincipal({ orgId: 'org_foreign' }),
1759
+ ).request(`/v1/projects/${project.id}/resource`)
1350
1760
 
1351
1761
  expect(response.status).toMatchInlineSnapshot(`404`)
1352
1762
  expect(await response.json()).toMatchInlineSnapshot(`
@@ -1370,14 +1780,16 @@ describe('ensureProject', () => {
1370
1780
  const response = await createProjectGuardApp(db, sessionPrincipal(user.id)).request(
1371
1781
  `/v1/projects/${project.id}/resource`,
1372
1782
  )
1373
- const body = (await response.json()) as Record<string, unknown>
1783
+ const { orgId, projectId, ...body } = await response.json()
1374
1784
 
1375
1785
  expect(response.status).toMatchInlineSnapshot(`200`)
1376
- expect(body).toEqual({
1377
- membership: 'member',
1378
- orgId: organization.id,
1379
- projectId: project.id,
1380
- })
1786
+ expect(orgId).toBe(organization.id)
1787
+ expect(projectId).toBe(project.id)
1788
+ expect(body).toMatchInlineSnapshot(`
1789
+ {
1790
+ "membership": "member",
1791
+ }
1792
+ `)
1381
1793
  })
1382
1794
 
1383
1795
  test('preserves nested organization role authorization', async () => {
@@ -1402,6 +1814,26 @@ describe('ensureProject', () => {
1402
1814
  }
1403
1815
  `)
1404
1816
  })
1817
+
1818
+ test('hides another nested project from a project-attributed API key', async () => {
1819
+ const db = TestApp.database()
1820
+ const organization = await Organizations.create(db, { id: key.orgId, name: 'Test org' })
1821
+ const project = await Projects.create(db, { name: 'Test project', orgId: organization.id })
1822
+ const response = await createNestedProjectGuardApp(
1823
+ db,
1824
+ apiKeyPrincipal({ orgId: organization.id, projectId: 'prj_other' }),
1825
+ ).request(`/v1/orgs/${organization.id}/projects/${project.id}/resource`)
1826
+
1827
+ expect(response.status).toMatchInlineSnapshot(`404`)
1828
+ expect(await response.json()).toMatchInlineSnapshot(`
1829
+ {
1830
+ "error": {
1831
+ "code": "project_not_found",
1832
+ "message": "Project not found",
1833
+ },
1834
+ }
1835
+ `)
1836
+ })
1405
1837
  })
1406
1838
 
1407
1839
  function createScopedApp(options: {
@@ -1467,13 +1899,21 @@ function projectGuardHandler(c: Context<GuardEnvironment>) {
1467
1899
  })
1468
1900
  }
1469
1901
 
1470
- function apiKeyPrincipal(orgId: string): Auth.Principal {
1471
- const apiKey = { ...key, orgId }
1902
+ function apiKeyPrincipal(options: {
1903
+ orgId: string
1904
+ projectId?: string | undefined
1905
+ }): Auth.Principal {
1906
+ const apiKey = {
1907
+ ...key,
1908
+ orgId: options.orgId,
1909
+ ...(options.projectId === undefined ? {} : { projectId: options.projectId }),
1910
+ }
1472
1911
  return {
1473
1912
  apiKey,
1474
1913
  environment: apiKey.environment,
1475
1914
  id: apiKey.id,
1476
- orgId,
1915
+ orgId: options.orgId,
1916
+ ...(options.projectId === undefined ? {} : { projectId: options.projectId }),
1477
1917
  type: 'api_key',
1478
1918
  }
1479
1919
  }
@@ -1516,15 +1956,18 @@ function createApp(options: Options = {}) {
1516
1956
  return app
1517
1957
  }
1518
1958
 
1519
- function createMppServer() {
1959
+ function createMppServer(options: createMppServer.Options = {}) {
1520
1960
  return Mppx.create({
1521
1961
  methods: [
1522
- tempo.charge({
1962
+ tempo.session({
1963
+ bootstrap: false,
1523
1964
  chainId: Tempo.chain.id,
1524
1965
  currency: Tempo.currency,
1525
1966
  decimals: 6,
1526
1967
  getClient: () => Tempo.client,
1527
1968
  recipient: Tempo.accounts[2].address,
1969
+ ...options,
1970
+ unitType: 'request',
1528
1971
  }),
1529
1972
  ],
1530
1973
  realm: 'tempo-api-test',
@@ -1532,6 +1975,37 @@ function createMppServer() {
1532
1975
  })
1533
1976
  }
1534
1977
 
1978
+ declare namespace createMppServer {
1979
+ type Options = Pick<
1980
+ NonNullable<Parameters<typeof tempo.session>[0]>,
1981
+ 'chainId' | 'getClient' | 'store'
1982
+ >
1983
+ }
1984
+
1985
+ function createMppHandler(
1986
+ options: createMppHandler.Options = {},
1987
+ ): NonNullable<Auth.Context['mpp']> {
1988
+ return {
1989
+ ...(options.rateLimit ? { rateLimit: options.rateLimit } : {}),
1990
+ session: () => async (_c, next) => {
1991
+ options.onPayment?.()
1992
+ await next()
1993
+ },
1994
+ }
1995
+ }
1996
+
1997
+ declare namespace createMppHandler {
1998
+ type Options = {
1999
+ onPayment?: (() => void) | undefined
2000
+ rateLimit?: RateLimit.Limit | undefined
2001
+ }
2002
+ }
2003
+
2004
+ /** Reads the session receipt attached to a paid response. */
2005
+ function sessionReceipt(response: Response) {
2006
+ return Receipt.fromResponse(response) as Session.Precompile.Protocol.SessionReceipt
2007
+ }
2008
+
1535
2009
  function overQuota(): RateLimit.Store {
1536
2010
  return {
1537
2011
  async consume(options) {
@@ -1545,12 +2019,29 @@ function overQuota(): RateLimit.Store {
1545
2019
  }
1546
2020
  }
1547
2021
 
1548
- function overQuotaOnce(): RateLimit.Store {
1549
- let consumed = false
2022
+ function underQuota(): RateLimit.Store {
1550
2023
  return {
1551
2024
  async consume(options) {
1552
- if (consumed) throw new Error('rate limit should not be consumed after payment')
1553
- consumed = true
2025
+ return {
2026
+ allowed: true,
2027
+ limit: options.limit.perMinute,
2028
+ remaining: Math.max(options.limit.perMinute - 1, 0),
2029
+ reset: Math.ceil(Date.now() / 1_000) + 60,
2030
+ }
2031
+ },
2032
+ }
2033
+ }
2034
+
2035
+ function overQuotaExceptMpp(): RateLimit.Store {
2036
+ return {
2037
+ async consume(options) {
2038
+ if (options.key.startsWith('mpp:'))
2039
+ return {
2040
+ allowed: true,
2041
+ limit: options.limit.perMinute,
2042
+ remaining: Math.max(options.limit.perMinute - 1, 0),
2043
+ reset: Math.ceil(Date.now() / 1_000) + 60,
2044
+ }
1554
2045
  return {
1555
2046
  allowed: false,
1556
2047
  limit: options.limit.perMinute,