tapimo 0.15.0 → 0.15.1

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 (91) hide show
  1. package/dist/App.d.ts +26 -26
  2. package/dist/admin/App.d.ts +3 -0
  3. package/dist/admin/App.d.ts.map +1 -1
  4. package/dist/admin/apps/api-keys.d.ts +41 -41
  5. package/dist/admin/apps/routes.d.ts +64 -64
  6. package/dist/admin/apps/verified-tokens.d.ts +161 -155
  7. package/dist/admin/apps/verified-tokens.d.ts.map +1 -1
  8. package/dist/admin/apps/verified-tokens.js +24 -4
  9. package/dist/admin/apps/verified-tokens.js.map +1 -1
  10. package/dist/admin/ui.generated.d.ts +1 -1
  11. package/dist/admin/ui.generated.d.ts.map +1 -1
  12. package/dist/admin/ui.generated.js +201 -209
  13. package/dist/admin/ui.generated.js.map +1 -1
  14. package/dist/apps/data/routes/activities.d.ts +357 -357
  15. package/dist/apps/data/routes/blocks.d.ts +125 -125
  16. package/dist/apps/data/routes/fee-amm.d.ts +108 -108
  17. package/dist/apps/data/routes/fee-amm.d.ts.map +1 -1
  18. package/dist/apps/data/routes/fee-amm.js +10 -2
  19. package/dist/apps/data/routes/fee-amm.js.map +1 -1
  20. package/dist/apps/data/routes/indexer.d.ts +43 -43
  21. package/dist/apps/data/routes/receipts.d.ts +235 -235
  22. package/dist/apps/data/routes/rpc.js +45 -11
  23. package/dist/apps/data/routes/rpc.js.map +1 -1
  24. package/dist/apps/data/routes/transfers.d.ts +81 -81
  25. package/dist/apps/data/routes/valuation.d.ts +32 -32
  26. package/dist/apps/data/routes/verified-tokens.d.ts +86 -86
  27. package/dist/apps/data/routes/webhooks.d.ts +1 -1
  28. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  29. package/dist/apps/data/routes/webhooks.js +17 -10
  30. package/dist/apps/data/routes/webhooks.js.map +1 -1
  31. package/dist/apps/data/routes/zones.d.ts +31 -31
  32. package/dist/apps/management/App.d.ts +26 -26
  33. package/dist/apps/management/routes/api-keys.d.ts +246 -246
  34. package/dist/apps/management/routes/invitations.d.ts +138 -138
  35. package/dist/apps/management/routes/invite-links.d.ts +168 -168
  36. package/dist/apps/management/routes/orgs.d.ts +98 -98
  37. package/dist/apps/management/routes/projects.d.ts +125 -125
  38. package/dist/apps/management/routes/verified-token-requests.d.ts +95 -95
  39. package/dist/apps/management/routes/verified-token-requests.d.ts.map +1 -1
  40. package/dist/apps/management/routes/verified-token-requests.js +7 -9
  41. package/dist/apps/management/routes/verified-token-requests.js.map +1 -1
  42. package/dist/apps/management/routes/webhooks.d.ts +423 -423
  43. package/dist/apps/mcp.d.ts.map +1 -1
  44. package/dist/apps/mcp.js +16 -3
  45. package/dist/apps/mcp.js.map +1 -1
  46. package/dist/apps/mpp/App.d.ts +44 -44
  47. package/dist/apps/routes/routes/chains.d.ts +25 -25
  48. package/dist/apps/routes/routes/quotes.d.ts +87 -87
  49. package/dist/apps/routes/routes/transfers.d.ts +438 -438
  50. package/dist/db/Db.d.ts.map +1 -1
  51. package/dist/db/Db.js +5 -0
  52. package/dist/db/Db.js.map +1 -1
  53. package/dist/db/tables/verifiedTokenRequests.d.ts +21 -4
  54. package/dist/db/tables/verifiedTokenRequests.d.ts.map +1 -1
  55. package/dist/db/tables/verifiedTokenRequests.js +65 -44
  56. package/dist/db/tables/verifiedTokenRequests.js.map +1 -1
  57. package/dist/internal/Auth.d.ts +16 -0
  58. package/dist/internal/Auth.d.ts.map +1 -1
  59. package/dist/internal/Auth.js +31 -8
  60. package/dist/internal/Auth.js.map +1 -1
  61. package/dist/internal/routes/providers/relay.d.ts +1 -1
  62. package/dist/internal/routes/providers/relay.js +1 -1
  63. package/dist/internal/routes/providers/relay.js.map +1 -1
  64. package/package.json +1 -1
  65. package/src/admin/apps/verified-tokens.test.ts +32 -0
  66. package/src/admin/apps/verified-tokens.ts +30 -4
  67. package/src/admin/ui/src/lib/verified-tokens.ts +24 -9
  68. package/src/admin/ui/src/routes/__root.tsx +1 -1
  69. package/src/admin/ui/src/routes/organizations.tsx +34 -4
  70. package/src/admin/ui/src/routes/verified-tokens.tsx +64 -20
  71. package/src/admin/ui.generated.ts +202 -263
  72. package/src/apps/data/App.test.ts +26 -0
  73. package/src/apps/data/routes/fee-amm.test.ts +19 -0
  74. package/src/apps/data/routes/fee-amm.ts +10 -2
  75. package/src/apps/data/routes/rpc.test.ts +32 -0
  76. package/src/apps/data/routes/rpc.ts +47 -10
  77. package/src/apps/data/routes/webhooks.scan.test.ts +29 -0
  78. package/src/apps/data/routes/webhooks.test.ts +6 -0
  79. package/src/apps/data/routes/webhooks.ts +25 -12
  80. package/src/apps/management/routes/verified-token-requests.test.ts +16 -2
  81. package/src/apps/management/routes/verified-token-requests.ts +7 -10
  82. package/src/apps/mcp.test.ts +108 -25
  83. package/src/apps/mcp.ts +15 -3
  84. package/src/db/Db.ts +9 -0
  85. package/src/db/tables/routesDeposits.test.ts +72 -0
  86. package/src/db/tables/verifiedTokenRequests.test.ts +39 -20
  87. package/src/db/tables/verifiedTokenRequests.ts +80 -44
  88. package/src/internal/Auth.test.ts +92 -0
  89. package/src/internal/Auth.ts +51 -9
  90. package/src/internal/routes/providers/relay.test.ts +59 -0
  91. package/src/internal/routes/providers/relay.ts +1 -1
package/src/db/Db.ts CHANGED
@@ -2971,6 +2971,15 @@ export const migrations: readonly Migration[] = [
2971
2971
  sql`INSERT INTO reward_campaign_distributors (chain_id, vault_address, distributor_address, created_at) SELECT chain_id, vault_address, '0x6c857bf1fe7de1bc1d5c639c721b2633f8fe34f3', created_at FROM reward_campaigns WHERE chain_id = 4217 AND vault_address = '0xd730394f3bb85a4828e35fc5e361dcc9f894fa4f' ON CONFLICT DO NOTHING`,
2972
2972
  ),
2973
2973
  },
2974
+ {
2975
+ // Provider subsidy billing first became active with this production Worker deployment.
2976
+ name: '0270_routes_deposits_legacy_provider_subsidies_reported',
2977
+ up: (db) =>
2978
+ raw(
2979
+ db,
2980
+ sql`UPDATE routes_deposits SET subsidy_meter_reported_at = coalesce(status_updated_at, updated_at) WHERE environment = 'production' AND subsidy_meter_reported_at IS NULL AND (provider_subsidy->'amount'->>'baseUnits')::numeric > 0 AND (status = 'completed' OR (status IN ('action-required', 'settling') AND tempo_gas_paid IS NOT NULL)) AND coalesce(status_updated_at, updated_at) < '2026-09-03T06:10:21.000Z'`,
2981
+ ),
2982
+ },
2974
2983
  ]
2975
2984
 
2976
2985
  /**
@@ -849,4 +849,76 @@ describe('subsidy reporting', () => {
849
849
  completed.record.statusUpdatedAt,
850
850
  )
851
851
  })
852
+
853
+ test('migration: excludes provider subsidies finalized before provider billing activation', async () => {
854
+ const before = new Date('2026-09-03T06:10:20.999Z')
855
+ const after = new Date('2026-09-03T06:10:21.001Z')
856
+ const address = await seedAddress({ orgId: 'org_legacy_provider_subsidy' })
857
+ const subsidy = {
858
+ amount: {
859
+ baseUnits: '23838',
860
+ currency: 'USD',
861
+ decimals: 6,
862
+ formatted: '0.023838',
863
+ },
864
+ provider: 'relay',
865
+ token: address.snapshot.sourceToken,
866
+ } as const
867
+ const legacy = await seedDeposit(address, { providerSubsidy: subsidy })
868
+ const current = await seedDeposit(address, { providerSubsidy: subsidy })
869
+ const inFlight = await seedDeposit(address, { providerSubsidy: subsidy })
870
+ vi.useFakeTimers({ now: before, toFake: ['Date'] })
871
+ try {
872
+ const completedLegacy = await Deposit.transition(db, {
873
+ expectedVersion: legacy.version,
874
+ id: legacy.id,
875
+ status: 'completed',
876
+ })
877
+ if (completedLegacy.type !== 'applied') throw new Error('Expected completed legacy subsidy.')
878
+ vi.setSystemTime(after)
879
+ const completedCurrent = await Deposit.transition(db, {
880
+ expectedVersion: current.version,
881
+ id: current.id,
882
+ status: 'completed',
883
+ })
884
+ if (completedCurrent.type !== 'applied')
885
+ throw new Error('Expected completed current subsidy.')
886
+ } finally {
887
+ vi.useRealTimers()
888
+ }
889
+
890
+ expect(
891
+ (await RoutesDeposits.listUnreportedSubsidies(db)).map(({ id }) => id).toSorted(),
892
+ ).toEqual([current.id, legacy.id].toSorted())
893
+
894
+ const migration = Db.migrations.find(
895
+ (candidate) => candidate.name === '0270_routes_deposits_legacy_provider_subsidies_reported',
896
+ )
897
+ if (!migration) throw new Error('Expected the legacy provider subsidy migration.')
898
+ await migration.up(db.kysely).execute()
899
+
900
+ expect((await RoutesDeposits.listUnreportedSubsidies(db)).map(({ id }) => id)).toEqual([
901
+ current.id,
902
+ ])
903
+ expect((await RoutesDeposits.get(db, legacy.id))?.subsidyMeterReportedAt).toBe(
904
+ before.toISOString(),
905
+ )
906
+ expect((await RoutesDeposits.get(db, current.id))?.subsidyMeterReportedAt).toBeNull()
907
+
908
+ vi.useFakeTimers({ now: after, toFake: ['Date'] })
909
+ try {
910
+ const completedInFlight = await Deposit.transition(db, {
911
+ expectedVersion: inFlight.version,
912
+ id: inFlight.id,
913
+ status: 'completed',
914
+ })
915
+ if (completedInFlight.type !== 'applied')
916
+ throw new Error('Expected completed in-flight subsidy.')
917
+ } finally {
918
+ vi.useRealTimers()
919
+ }
920
+ expect(
921
+ (await RoutesDeposits.listUnreportedSubsidies(db)).map(({ id }) => id).toSorted(),
922
+ ).toEqual([current.id, inFlight.id].toSorted())
923
+ })
852
924
  })
@@ -70,6 +70,26 @@ describe('create', () => {
70
70
  const other = await VerifiedTokenRequests.create(db, { ...input, chainId: 4217 })
71
71
  expect(other).toBeDefined()
72
72
  })
73
+
74
+ test("atomically caps pending requests across a requester's organizations", async () => {
75
+ const db = TestApp.database()
76
+ const attempts = await Promise.allSettled(
77
+ Array.from({ length: VerifiedTokenRequests.maxPendingPerRequester + 1 }, (_, index) =>
78
+ VerifiedTokenRequests.create(db, {
79
+ ...input,
80
+ address: `0x${(index + 1).toString(16).padStart(40, '0')}`,
81
+ orgId: `org_${index}`,
82
+ symbol: `T${index}`,
83
+ }),
84
+ ),
85
+ )
86
+ expect(attempts.filter((attempt) => attempt.status === 'fulfilled')).toHaveLength(
87
+ VerifiedTokenRequests.maxPendingPerRequester,
88
+ )
89
+ expect(attempts.filter((attempt) => attempt.status === 'rejected')).toMatchObject([
90
+ { reason: { name: 'VerifiedTokenRequests.PendingLimitError' } },
91
+ ])
92
+ })
73
93
  })
74
94
 
75
95
  describe('list', () => {
@@ -108,38 +128,37 @@ describe('listByStatus', () => {
108
128
  status: 'denied',
109
129
  })
110
130
 
111
- const queue = await VerifiedTokenRequests.listByStatus(db, 'pending')
131
+ const queue = await VerifiedTokenRequests.listByStatus(db, 'pending', { limit: 10 })
112
132
  expect(queue.map(({ id, orgName }) => ({ id, orgName }))).toEqual([
113
133
  { id: pending!.id, orgName: 'Acme' },
114
134
  ])
115
135
  // The denied row's org has no organizations row; the join yields null.
116
- const reviewed = await VerifiedTokenRequests.listByStatus(db, 'denied')
136
+ const reviewed = await VerifiedTokenRequests.listByStatus(db, 'denied', { limit: 10 })
117
137
  expect(reviewed.map(({ id, orgName }) => ({ id, orgName }))).toEqual([
118
138
  { id: denied!.id, orgName: null },
119
139
  ])
120
140
  })
121
- })
122
141
 
123
- describe('countPending', () => {
124
- test('counts only the organization pending requests', async () => {
142
+ test('continues after a stable oldest-first cursor', async () => {
125
143
  const db = TestApp.database()
144
+ for (const [suffix, symbol] of [
145
+ ['bb', 'tEUR'],
146
+ ['cc', 'tGBP'],
147
+ ] as const)
148
+ await VerifiedTokenRequests.create(db, {
149
+ ...input,
150
+ address: `0x20c00000000000000000000000000000000000${suffix}`,
151
+ symbol,
152
+ })
126
153
  await VerifiedTokenRequests.create(db, input)
127
- const denied = await VerifiedTokenRequests.create(db, {
128
- ...input,
129
- address: '0x20c00000000000000000000000000000000000bb',
130
- symbol: 'tEUR',
131
- })
132
- await VerifiedTokenRequests.review(db, denied!.id, {
133
- reviewedBy: 'admin@tempo.xyz',
134
- status: 'denied',
135
- })
136
- await VerifiedTokenRequests.create(db, {
137
- ...input,
138
- address: '0x20c00000000000000000000000000000000000cc',
139
- orgId: 'org_2b3c4d5e6f7g8h9j0k1m2n3p',
140
- symbol: 'tGBP',
154
+ const all = await VerifiedTokenRequests.listByStatus(db, 'pending', { limit: 10 })
155
+ const first = await VerifiedTokenRequests.listByStatus(db, 'pending', { limit: 2 })
156
+ const last = first.at(-1)!
157
+ const second = await VerifiedTokenRequests.listByStatus(db, 'pending', {
158
+ cursor: { createdAt: last.createdAt, id: last.id },
159
+ limit: 2,
141
160
  })
142
- expect(await VerifiedTokenRequests.countPending(db, input.orgId)).toBe(1)
161
+ expect([...first, ...second].map(({ id }) => id)).toEqual(all.map(({ id }) => id))
143
162
  })
144
163
  })
145
164
 
@@ -11,44 +11,61 @@ export type Record = Selectable<Table>
11
11
  /** Review status of a request. */
12
12
  export type Status = Record['status']
13
13
 
14
+ /** Maximum pending verification requests retained for one requester. */
15
+ export const maxPendingPerRequester = 10
16
+
14
17
  /**
15
18
  * Inserts a pending request; `undefined` when a pending request for
16
- * (chainId, address) already exists.
19
+ * (chainId, address) already exists. Throws `PendingLimitError` when the
20
+ * requester already has the maximum retained pending requests.
17
21
  */
18
22
  export function create(db: Db.Db, input: create.Input): Promise<Record | undefined> {
19
23
  const now = new Date().toISOString()
20
- return db.kysely
21
- .insertInto('verified_token_requests')
22
- .values({
23
- address: input.address,
24
- chainId: input.chainId,
25
- createdAt: now,
26
- currency: input.currency,
27
- decimals: input.decimals,
28
- id: Id.generate('vtr'),
29
- logo: input.logo ?? null,
30
- logoUri: input.logoUri ?? null,
31
- name: input.name,
32
- note: input.note ?? null,
33
- orgId: input.orgId,
34
- requestedBy: input.requestedBy,
35
- reviewNote: null,
36
- reviewedAt: null,
37
- reviewedBy: null,
38
- status: 'pending',
39
- symbol: input.symbol,
40
- updatedAt: now,
41
- })
42
- .onConflict((oc) =>
43
- oc
44
- .columns(['chainId', 'address'])
45
- // `sql.lit` keeps the predicate literal; a bound parameter would
46
- // defeat partial-index inference for the conflict target.
47
- .where('status', '=', sql.lit('pending'))
48
- .doNothing(),
24
+ return db.transaction(async (tx) => {
25
+ // Serialize admission per requester so parallel organizations cannot exceed the retained queue cap.
26
+ await sql`SELECT pg_advisory_xact_lock(hashtextextended(${`verified-token-requests:${input.requestedBy}`}, 0))`.execute(
27
+ tx.kysely,
49
28
  )
50
- .returningAll()
51
- .executeTakeFirst()
29
+ const pending = await tx.kysely
30
+ .selectFrom('verified_token_requests')
31
+ .select(({ fn }) => fn.countAll<string>().as('count'))
32
+ .where('requestedBy', '=', input.requestedBy)
33
+ .where('status', '=', 'pending')
34
+ .executeTakeFirstOrThrow()
35
+ if (Number(pending.count) >= maxPendingPerRequester) throw new PendingLimitError()
36
+ return tx.kysely
37
+ .insertInto('verified_token_requests')
38
+ .values({
39
+ address: input.address,
40
+ chainId: input.chainId,
41
+ createdAt: now,
42
+ currency: input.currency,
43
+ decimals: input.decimals,
44
+ id: Id.generate('vtr'),
45
+ logo: input.logo ?? null,
46
+ logoUri: input.logoUri ?? null,
47
+ name: input.name,
48
+ note: input.note ?? null,
49
+ orgId: input.orgId,
50
+ requestedBy: input.requestedBy,
51
+ reviewNote: null,
52
+ reviewedAt: null,
53
+ reviewedBy: null,
54
+ status: 'pending',
55
+ symbol: input.symbol,
56
+ updatedAt: now,
57
+ })
58
+ .onConflict((oc) =>
59
+ oc
60
+ .columns(['chainId', 'address'])
61
+ // `sql.lit` keeps the predicate literal; a bound parameter would
62
+ // defeat partial-index inference for the conflict target.
63
+ .where('status', '=', sql.lit('pending'))
64
+ .doNothing(),
65
+ )
66
+ .returningAll()
67
+ .executeTakeFirst()
68
+ })
52
69
  }
53
70
 
54
71
  export declare namespace create {
@@ -96,17 +113,42 @@ export function list(db: Db.Db, orgId: string): Promise<Record[]> {
96
113
  export function listByStatus(
97
114
  db: Db.Db,
98
115
  status: Status,
116
+ options: listByStatus.Options,
99
117
  ): Promise<(Record & { orgName: string | null })[]> {
100
- return db.kysely
118
+ const { cursor } = options
119
+ let query = db.kysely
101
120
  .selectFrom('verified_token_requests')
102
121
  .leftJoin('organizations', 'organizations.id', 'verified_token_requests.orgId')
103
122
  .selectAll('verified_token_requests')
104
123
  .select('organizations.name as orgName')
105
124
  .where('status', '=', status)
125
+ if (cursor)
126
+ query = query.where((eb) =>
127
+ eb.or([
128
+ eb('verified_token_requests.createdAt', '>', cursor.createdAt),
129
+ eb.and([
130
+ eb('verified_token_requests.createdAt', '=', cursor.createdAt),
131
+ eb('verified_token_requests.id', '>', cursor.id),
132
+ ]),
133
+ ]),
134
+ )
135
+ return query
106
136
  .orderBy('verified_token_requests.createdAt', 'asc')
137
+ .orderBy('verified_token_requests.id', 'asc')
138
+ .limit(options.limit)
107
139
  .execute()
108
140
  }
109
141
 
142
+ export declare namespace listByStatus {
143
+ /** Pagination options for the global review queue. */
144
+ type Options = {
145
+ /** Oldest-first position after which to continue. */
146
+ cursor?: { createdAt: string; id: string } | undefined
147
+ /** Maximum records to return. */
148
+ limit: number
149
+ }
150
+ }
151
+
110
152
  /** Gets a request by id. */
111
153
  export function get(db: Db.Db, id: string): Promise<Record | undefined> {
112
154
  return db.kysely
@@ -116,17 +158,6 @@ export function get(db: Db.Db, id: string): Promise<Record | undefined> {
116
158
  .executeTakeFirst()
117
159
  }
118
160
 
119
- /** Counts an organization's pending requests. */
120
- export async function countPending(db: Db.Db, orgId: string): Promise<number> {
121
- const row = await db.kysely
122
- .selectFrom('verified_token_requests')
123
- .select(({ fn }) => fn.countAll<string>().as('count'))
124
- .where('orgId', '=', orgId)
125
- .where('status', '=', 'pending')
126
- .executeTakeFirstOrThrow()
127
- return Number(row.count)
128
- }
129
-
130
161
  /** Hard-deletes an org-scoped request while pending; `undefined` otherwise. */
131
162
  export function remove(db: Db.Db, orgId: string, id: string): Promise<Record | undefined> {
132
163
  return db.kysely
@@ -180,3 +211,8 @@ export function toRequest(record: Record) {
180
211
  ...(logoUri === null ? {} : { logoUri }),
181
212
  }
182
213
  }
214
+
215
+ /** The requester already has the maximum retained pending requests. */
216
+ export class PendingLimitError extends Error {
217
+ override readonly name = 'VerifiedTokenRequests.PendingLimitError'
218
+ }
@@ -75,6 +75,36 @@ describe('require', () => {
75
75
  `)
76
76
  })
77
77
 
78
+ test('isolates an explicit endpoint bucket from the shared scope quota', async () => {
79
+ const app = createScopedApp({
80
+ routes: [
81
+ {
82
+ path: '/other',
83
+ policy: {
84
+ apiKey: { rateLimit: { limit: 1, period: 'minute' }, scopes: ['data:read'] },
85
+ },
86
+ },
87
+ {
88
+ path: '/protected',
89
+ policy: {
90
+ apiKey: {
91
+ bucket: 'protected',
92
+ rateLimit: { limit: 1, period: 'minute' },
93
+ scopes: ['data:read'],
94
+ },
95
+ },
96
+ },
97
+ ],
98
+ })
99
+ const options = { headers: { 'tempo-api-key': token } }
100
+
101
+ const other = await app.request('/other', options)
102
+ const protected_ = await app.request('/protected', options)
103
+ const overflow = await app.request('/protected', options)
104
+
105
+ expect([other.status, protected_.status, overflow.status]).toEqual([200, 200, 429])
106
+ })
107
+
78
108
  test('does not parse payment authorization as bearer fallback', async () => {
79
109
  const app = createApp()
80
110
  const response = await app.request('/protected', {
@@ -1041,6 +1071,42 @@ describe('require', () => {
1041
1071
  expect(response.headers.get('RateLimit-Limit')).toBe('25')
1042
1072
  })
1043
1073
 
1074
+ test('isolates an explicit endpoint bucket from unrelated paid requests', async () => {
1075
+ const auth = {
1076
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
1077
+ mpp: createMppHandler(),
1078
+ rateLimit: RateLimit.memory(),
1079
+ } satisfies Auth.Context
1080
+ const app = createScopedApp({
1081
+ auth,
1082
+ routes: [
1083
+ {
1084
+ path: '/other',
1085
+ policy: {
1086
+ mpp: { rateLimit: { limit: 1, period: 'minute' }, session: { amount: '1' } },
1087
+ },
1088
+ },
1089
+ {
1090
+ path: '/protected',
1091
+ policy: {
1092
+ mpp: {
1093
+ bucket: 'protected',
1094
+ rateLimit: { limit: 1, period: 'minute' },
1095
+ session: { amount: '1' },
1096
+ },
1097
+ },
1098
+ },
1099
+ ],
1100
+ })
1101
+ const options = { headers: { authorization: 'Payment paid' } }
1102
+
1103
+ const other = await app.request('/other', options)
1104
+ const protected_ = await app.request('/protected', options)
1105
+ const overflow = await app.request('/protected', options)
1106
+
1107
+ expect([other.status, protected_.status, overflow.status]).toEqual([200, 200, 429])
1108
+ })
1109
+
1044
1110
  test('route paid-request limit overrides the global limit', async () => {
1045
1111
  const app = createApp({
1046
1112
  auth: {
@@ -1648,6 +1714,32 @@ describe('require', () => {
1648
1714
  `)
1649
1715
  })
1650
1716
 
1717
+ test('isolates an explicit endpoint bucket from unrelated public requests', async () => {
1718
+ const app = createScopedApp({
1719
+ routes: [
1720
+ {
1721
+ path: '/other',
1722
+ policy: { public: { rateLimit: { limit: 1, period: 'minute' } } },
1723
+ },
1724
+ {
1725
+ path: '/protected',
1726
+ policy: {
1727
+ public: {
1728
+ bucket: 'protected',
1729
+ rateLimit: { limit: 1, period: 'minute' },
1730
+ },
1731
+ },
1732
+ },
1733
+ ],
1734
+ })
1735
+
1736
+ const other = await app.request('/other')
1737
+ const protected_ = await app.request('/protected')
1738
+ const overflow = await app.request('/protected')
1739
+
1740
+ expect([other.status, protected_.status, overflow.status]).toEqual([200, 200, 429])
1741
+ })
1742
+
1651
1743
  test('returns rate-limit error without payment context', async () => {
1652
1744
  const app = createApp({
1653
1745
  auth: {
@@ -44,6 +44,7 @@ import type * as Viem from './Viem.js'
44
44
 
45
45
  const policySymbol = Symbol('tempo-api.auth.policy')
46
46
  const mppManagementSymbol = Symbol('tempo-api.auth.mpp-management')
47
+ const trustedClientIps = new WeakMap<Request, string>()
47
48
  const defaultApiKeyRateLimit = { limit: 10_000, period: 'minute' } satisfies RateLimit.Limit
48
49
  const defaultIdentitySessionTtl = 24 * 60 * 60
49
50
  const defaultMppRateLimit = { limit: 100, period: 'minute' } satisfies RateLimit.Limit
@@ -186,6 +187,8 @@ export type PolicyOverride = {
186
187
  /** API-key lane: pass quota/scopes overrides, or a boolean to enable/disable. */
187
188
  apiKey?:
188
189
  | {
190
+ /** Optional counter bucket that isolates this route's quota. */
191
+ bucket?: string | undefined
189
192
  /** API-key quota override for this route (a protective per-route cap). */
190
193
  rateLimit?: RateLimit.Limit | undefined
191
194
  /** Required API-key scopes override for this route. */
@@ -196,6 +199,8 @@ export type PolicyOverride = {
196
199
  /** MPP paid lane: object to override the Session request, or boolean to enable/disable. */
197
200
  mpp?:
198
201
  | {
202
+ /** Optional counter bucket that isolates this route's quota. */
203
+ bucket?: string | undefined
199
204
  /** Paid-request quota override for this route (a protective per-route cap). */
200
205
  rateLimit?: RateLimit.Limit | undefined
201
206
  /** Tempo Session request override for this route. */
@@ -206,6 +211,8 @@ export type PolicyOverride = {
206
211
  /** Public lane: object to override the quota, or boolean to enable/disable. */
207
212
  public?:
208
213
  | {
214
+ /** Optional counter bucket that isolates this route's quota. */
215
+ bucket?: string | undefined
209
216
  /** Anonymous public quota override for this route. */
210
217
  rateLimit?: RateLimit.Limit | undefined
211
218
  }
@@ -288,6 +295,17 @@ export function getPrincipal<environment extends Environment>(c: hono_Context<en
288
295
  return c.get('principal') ?? null
289
296
  }
290
297
 
298
+ /** Resolves the caller IP from configured trusted metadata. */
299
+ export function getTrustedClientIp<environment extends Environment>(c: hono_Context<environment>) {
300
+ const auth = c.get('auth')
301
+ return auth ? trustedClientIp(c, auth) : resolveTrustedClientIp(c.req.raw, undefined)
302
+ }
303
+
304
+ /** Marks a synthesized in-process request with its trusted caller IP address. */
305
+ export function trustClientIp(request: Request, address: string) {
306
+ trustedClientIps.set(request, address)
307
+ }
308
+
291
309
  /**
292
310
  * Guards `:orgId` routes and resolves the organization. Sessions require membership, API keys require ownership, and the super admin bypasses ownership.
293
311
  */
@@ -666,8 +684,9 @@ function resolveRequestPolicy(c: hono_Context, options: resolveRequestPolicy.Opt
666
684
  const inheritedKey = `${inheritedMethod.toUpperCase()} ${key.slice(key.indexOf(' ') + 1)}`
667
685
  return overrides?.[inheritedKey]
668
686
  })()
669
- const routeOverride =
670
- management && configuredOverride ? { mpp: configuredOverride.mpp } : configuredOverride
687
+ // Generated management routes already materialize the GET's MPP contract.
688
+ // Reapplying its override would also copy GET-only quota controls.
689
+ const routeOverride = management ? undefined : configuredOverride
671
690
  return {
672
691
  management,
673
692
  policy: bindMppScope(
@@ -1612,7 +1631,9 @@ export function installMppManagementRoutes(
1612
1631
  if (postPaths.has(target.path))
1613
1632
  throw new Error(`MPP management POST conflicts with an existing route: ${target.path}`)
1614
1633
 
1615
- const management = policy({ mpp }) as MiddlewareHandler & MppManagementHandler
1634
+ const management = policy({
1635
+ mpp: { session: mpp.session },
1636
+ }) as MiddlewareHandler & MppManagementHandler
1616
1637
  management[mppManagementSymbol] = { description: target.description }
1617
1638
  app.post(
1618
1639
  stripBasePath(target.path, options.basePath),
@@ -1776,6 +1797,7 @@ function resolveApiKeyLane(
1776
1797
  if (override === true) return current ?? default_
1777
1798
  const base = current ?? default_
1778
1799
  return {
1800
+ bucket: override.bucket ?? base.bucket,
1779
1801
  rateLimit: override.rateLimit ?? base.rateLimit,
1780
1802
  scopes: override.scopes ?? base.scopes,
1781
1803
  }
@@ -1791,6 +1813,7 @@ function resolveMppLane(
1791
1813
  if (override === true) return current ?? default_
1792
1814
  const base = current ?? default_
1793
1815
  return {
1816
+ bucket: override.bucket ?? base.bucket,
1794
1817
  rateLimit: override.rateLimit ?? base.rateLimit,
1795
1818
  session: { ...base.session, ...override.session },
1796
1819
  }
@@ -1805,7 +1828,10 @@ function resolvePublicLane(
1805
1828
  if (override === false) return undefined
1806
1829
  if (override === true) return current ?? default_
1807
1830
  const base = current ?? default_
1808
- return { rateLimit: override.rateLimit ?? base.rateLimit }
1831
+ return {
1832
+ bucket: override.bucket ?? base.bucket,
1833
+ rateLimit: override.rateLimit ?? base.rateLimit,
1834
+ }
1809
1835
  }
1810
1836
 
1811
1837
  function resolveSessionLane(
@@ -2088,7 +2114,7 @@ export function require<environment extends Environment = Environment>(
2088
2114
  const result = paid
2089
2115
  ? null
2090
2116
  : await consumeRateLimit(auth, {
2091
- key: `api_key:${apiKey.id}:scope:${scope}`,
2117
+ key: rateLimitKey(`api_key:${apiKey.id}:scope:${scope}`, policy.apiKey.bucket),
2092
2118
  limit,
2093
2119
  })
2094
2120
  if (result) {
@@ -2188,7 +2214,7 @@ export function require<environment extends Environment = Environment>(
2188
2214
  const limit = policy.public?.rateLimit
2189
2215
  if (limit) {
2190
2216
  const result = await consumeRateLimit(auth, {
2191
- key: `public:${publicPrincipal.id}`,
2217
+ key: rateLimitKey(`public:${publicPrincipal.id}`, policy.public?.bucket),
2192
2218
  limit,
2193
2219
  })
2194
2220
  if (result) setRateLimitHeaders(c, result)
@@ -2231,6 +2257,8 @@ export declare namespace require {
2231
2257
  /** API-key access policy. */
2232
2258
  apiKey?:
2233
2259
  | {
2260
+ /** Optional counter bucket that isolates this route's quota. */
2261
+ bucket?: string | undefined
2234
2262
  /** API-key quota override for this route (a protective per-route cap). */
2235
2263
  rateLimit?: RateLimit.Limit | undefined
2236
2264
  /**
@@ -2244,6 +2272,8 @@ export declare namespace require {
2244
2272
  /** MPP payment access policy. */
2245
2273
  mpp?:
2246
2274
  | {
2275
+ /** Optional counter bucket that isolates this route's quota. */
2276
+ bucket?: string | undefined
2247
2277
  /** Paid-request quota override for this route (a protective per-route cap). */
2248
2278
  rateLimit?: RateLimit.Limit | undefined
2249
2279
  /** Tempo Session request policy. */
@@ -2253,6 +2283,8 @@ export declare namespace require {
2253
2283
  /** Public access policy. */
2254
2284
  public?:
2255
2285
  | {
2286
+ /** Optional counter bucket that isolates this route's quota. */
2287
+ bucket?: string | undefined
2256
2288
  /** Anonymous public quota for this route. */
2257
2289
  rateLimit: RateLimit.Limit
2258
2290
  }
@@ -2397,11 +2429,14 @@ function getApiKeyCredential(c: hono_Context): ApiKeyCredential | false | null {
2397
2429
  }
2398
2430
 
2399
2431
  function clientIp(c: hono_Context, auth: Context): string | undefined {
2400
- return auth.publicClientIp ? auth.publicClientIp(c.req.raw) : c.req.header('cf-connecting-ip')
2432
+ return (
2433
+ trustedClientIps.get(c.req.raw) ??
2434
+ (auth.publicClientIp ? auth.publicClientIp(c.req.raw) : c.req.header('cf-connecting-ip'))
2435
+ )
2401
2436
  }
2402
2437
 
2403
2438
  function trustedClientIp(c: hono_Context, auth: Context): string | undefined {
2404
- return resolveTrustedClientIp(c.req.raw, auth.publicClientIp)
2439
+ return trustedClientIps.get(c.req.raw) ?? resolveTrustedClientIp(c.req.raw, auth.publicClientIp)
2405
2440
  }
2406
2441
 
2407
2442
  function resolveTrustedClientIp(
@@ -2544,7 +2579,10 @@ async function requirePayment<environment extends Environment>(
2544
2579
  ) {
2545
2580
  if (hasPaymentCredential(c)) {
2546
2581
  const result = await consumeRateLimit(options.auth, {
2547
- key: `mpp:${options.principal.type}:${options.principal.id}`,
2582
+ key: rateLimitKey(
2583
+ `mpp:${options.principal.type}:${options.principal.id}`,
2584
+ options.policy.mpp?.bucket,
2585
+ ),
2548
2586
  limit: options.policy.mpp?.rateLimit ?? options.auth.mpp?.rateLimit ?? defaultMppRateLimit,
2549
2587
  })
2550
2588
  if (result) {
@@ -2601,6 +2639,10 @@ function requirePaymentOrRateLimit<environment extends Environment>(
2601
2639
  return rateLimitExceeded(c, options.rateLimit)
2602
2640
  }
2603
2641
 
2642
+ function rateLimitKey(key: string, bucket: string | undefined) {
2643
+ return bucket === undefined ? key : `${key}:bucket:${bucket}`
2644
+ }
2645
+
2604
2646
  /** Emits a `429` with a `Retry-After` derived from the window reset. */
2605
2647
  function rateLimitExceeded(c: hono_Context, result: RateLimit.Result) {
2606
2648
  c.header('Retry-After', String(Math.max(result.reset - Math.ceil(Date.now() / 1_000), 1)))