tapimo 0.5.1 → 0.5.2

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 (114) hide show
  1. package/dist/App.d.ts +34 -18
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/admin/apps/verified-tokens.d.ts +117 -117
  4. package/dist/apps/data/App.d.ts +3217 -3217
  5. package/dist/apps/data/FundingQuoteProviders.d.ts +32 -0
  6. package/dist/apps/data/FundingQuoteProviders.d.ts.map +1 -1
  7. package/dist/apps/data/FundingQuoteProviders.js +134 -1
  8. package/dist/apps/data/FundingQuoteProviders.js.map +1 -1
  9. package/dist/apps/data/routes/addresses.d.ts +206 -206
  10. package/dist/apps/data/routes/balances.d.ts +46 -46
  11. package/dist/apps/data/routes/coingecko.d.ts +169 -169
  12. package/dist/apps/data/routes/funding.d.ts +8 -2
  13. package/dist/apps/data/routes/funding.d.ts.map +1 -1
  14. package/dist/apps/data/routes/funding.js +4 -4
  15. package/dist/apps/data/routes/funding.js.map +1 -1
  16. package/dist/apps/data/routes/indexer.d.ts +34 -34
  17. package/dist/apps/data/routes/receipts.d.ts +228 -228
  18. package/dist/apps/data/routes/rpc.d.ts +31 -31
  19. package/dist/apps/data/routes/transfers.d.ts +8 -15
  20. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  21. package/dist/apps/data/routes/transfers.js +21 -27
  22. package/dist/apps/data/routes/transfers.js.map +1 -1
  23. package/dist/apps/data/routes/webhooks.d.ts +35 -35
  24. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  25. package/dist/apps/data/routes/webhooks.js +176 -42
  26. package/dist/apps/data/routes/webhooks.js.map +1 -1
  27. package/dist/apps/management/App.d.ts +982 -966
  28. package/dist/apps/management/App.d.ts.map +1 -1
  29. package/dist/apps/management/Billing.d.ts.map +1 -1
  30. package/dist/apps/management/Billing.js +5 -4
  31. package/dist/apps/management/Billing.js.map +1 -1
  32. package/dist/apps/management/routes/billing.d.ts.map +1 -1
  33. package/dist/apps/management/routes/billing.js +1 -1
  34. package/dist/apps/management/routes/billing.js.map +1 -1
  35. package/dist/apps/management/routes/invitations.d.ts +121 -121
  36. package/dist/apps/management/routes/invite-links.d.ts +174 -174
  37. package/dist/apps/management/routes/orgs.d.ts +102 -86
  38. package/dist/apps/management/routes/orgs.d.ts.map +1 -1
  39. package/dist/apps/management/routes/orgs.js +18 -1
  40. package/dist/apps/management/routes/orgs.js.map +1 -1
  41. package/dist/apps/management/routes/projects.d.ts +124 -124
  42. package/dist/apps/relay/Sponsorships.d.ts +3 -0
  43. package/dist/apps/relay/Sponsorships.d.ts.map +1 -1
  44. package/dist/apps/relay/Sponsorships.js +15 -1
  45. package/dist/apps/relay/Sponsorships.js.map +1 -1
  46. package/dist/db/Db.d.ts.map +1 -1
  47. package/dist/db/Db.js +4 -0
  48. package/dist/db/Db.js.map +1 -1
  49. package/dist/db/Schema.js +1 -1
  50. package/dist/db/Schema.js.map +1 -1
  51. package/dist/db/tables/organizations.d.ts +6 -0
  52. package/dist/db/tables/organizations.d.ts.map +1 -1
  53. package/dist/db/tables/organizations.js +26 -0
  54. package/dist/db/tables/organizations.js.map +1 -1
  55. package/dist/db/tables/requestUsage.d.ts +2 -11
  56. package/dist/db/tables/requestUsage.d.ts.map +1 -1
  57. package/dist/db/tables/requestUsage.js +1 -20
  58. package/dist/db/tables/requestUsage.js.map +1 -1
  59. package/dist/db/tables/sponsoredTransactions.d.ts +2 -1
  60. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  61. package/dist/db/tables/sponsoredTransactions.js +46 -8
  62. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  63. package/dist/internal/Auth.d.ts +4 -0
  64. package/dist/internal/Auth.d.ts.map +1 -1
  65. package/dist/internal/Auth.js +7 -8
  66. package/dist/internal/Auth.js.map +1 -1
  67. package/dist/internal/Cache.d.ts.map +1 -1
  68. package/dist/internal/Cache.js +9 -1
  69. package/dist/internal/Cache.js.map +1 -1
  70. package/dist/internal/EdgeCache.d.ts.map +1 -1
  71. package/dist/internal/EdgeCache.js +12 -3
  72. package/dist/internal/EdgeCache.js.map +1 -1
  73. package/dist/internal/FundingQuotes.d.ts +16 -10
  74. package/dist/internal/FundingQuotes.d.ts.map +1 -1
  75. package/dist/internal/FundingQuotes.js +9 -0
  76. package/dist/internal/FundingQuotes.js.map +1 -1
  77. package/dist/internal/RateLimit.d.ts +2 -0
  78. package/dist/internal/RateLimit.d.ts.map +1 -1
  79. package/dist/internal/RateLimit.js +4 -4
  80. package/dist/internal/RateLimit.js.map +1 -1
  81. package/package.json +6 -1
  82. package/src/App.test.ts +2 -2
  83. package/src/Client.test-d.ts +28 -2
  84. package/src/apps/data/FundingQuoteProviders.test.ts +184 -0
  85. package/src/apps/data/FundingQuoteProviders.ts +185 -1
  86. package/src/apps/data/routes/funding.test.ts +7 -0
  87. package/src/apps/data/routes/funding.ts +4 -4
  88. package/src/apps/data/routes/tokens.test.ts +54 -0
  89. package/src/apps/data/routes/transfers.test.ts +73 -1
  90. package/src/apps/data/routes/transfers.ts +24 -30
  91. package/src/apps/data/routes/webhooks.test.ts +203 -0
  92. package/src/apps/data/routes/webhooks.ts +191 -54
  93. package/src/apps/management/Billing.test.ts +50 -1
  94. package/src/apps/management/Billing.ts +5 -4
  95. package/src/apps/management/routes/billing.ts +6 -1
  96. package/src/apps/management/routes/orgs.ts +19 -1
  97. package/src/apps/relay/Sponsorships.ts +24 -1
  98. package/src/db/Db.ts +4 -0
  99. package/src/db/Schema.ts +1 -1
  100. package/src/db/tables/organizations.test.ts +30 -0
  101. package/src/db/tables/organizations.ts +28 -0
  102. package/src/db/tables/requestUsage.ts +3 -23
  103. package/src/db/tables/sponsoredTransactions.test.ts +41 -0
  104. package/src/db/tables/sponsoredTransactions.ts +46 -7
  105. package/src/internal/Auth.test.ts +63 -10
  106. package/src/internal/Auth.ts +11 -9
  107. package/src/internal/Cache.test.ts +19 -0
  108. package/src/internal/Cache.ts +13 -1
  109. package/src/internal/EdgeCache.test.ts +40 -0
  110. package/src/internal/EdgeCache.ts +11 -3
  111. package/src/internal/FundingQuotes.test.ts +33 -0
  112. package/src/internal/FundingQuotes.ts +9 -0
  113. package/src/internal/RateLimit.test.ts +12 -0
  114. package/src/internal/RateLimit.ts +4 -4
@@ -105,6 +105,16 @@ export declare namespace createOwned {
105
105
  */
106
106
  export function deleteOrganization(db: Db.Db, id: string): Promise<boolean> {
107
107
  return db.kysely.transaction().execute(async (trx) => {
108
+ const unreported = await trx
109
+ .selectFrom('sponsored_transactions')
110
+ .select('id')
111
+ .where('orgId', '=', id)
112
+ .where('billable', '=', true)
113
+ .where('meterReportedAt', 'is', null)
114
+ .limit(1)
115
+ .executeTakeFirst()
116
+ if (unreported) throw new UnreportedSponsorshipsError()
117
+
108
118
  await trx
109
119
  .deleteFrom('webhook_subscriptions')
110
120
  .where('ownerType', '=', 'api_key')
@@ -124,6 +134,19 @@ export function deleteOrganization(db: Db.Db, id: string): Promise<boolean> {
124
134
  })
125
135
  }
126
136
 
137
+ /** Whether an organization has billable sponsorships awaiting settlement. */
138
+ export async function hasUnreportedSponsorships(db: Db.Db, id: string): Promise<boolean> {
139
+ const record = await db.kysely
140
+ .selectFrom('sponsored_transactions')
141
+ .select('id')
142
+ .where('orgId', '=', id)
143
+ .where('billable', '=', true)
144
+ .where('meterReportedAt', 'is', null)
145
+ .limit(1)
146
+ .executeTakeFirst()
147
+ return record !== undefined
148
+ }
149
+
127
150
  /**
128
151
  * Reads an organization by id.
129
152
  *
@@ -389,3 +412,8 @@ export declare namespace upsert {
389
412
  name?: string | undefined
390
413
  }
391
414
  }
415
+
416
+ /** Organization deletion is blocked until billable sponsorships settle. */
417
+ export class UnreportedSponsorshipsError extends Error {
418
+ override name = 'Organizations.UnreportedSponsorshipsError'
419
+ }
@@ -32,7 +32,7 @@ export type MeterEventTable = {
32
32
  orgId: string
33
33
  reason: string | null
34
34
  reportedAt: string | null
35
- sequence: number
35
+ sequence: Count
36
36
  state: State
37
37
  stripeCustomerId: string
38
38
  }
@@ -91,26 +91,6 @@ export async function listPending(db: Db.Db, environment: Environment): Promise<
91
91
  return rows.map(toMeterEvent)
92
92
  }
93
93
 
94
- /**
95
- * Counts the meter events already reported for a bucket — the next delta's
96
- * `sequence`, so a new seal never collides with a settled one.
97
- *
98
- * @param db - The database.
99
- * @param key - The bucket key.
100
- * @returns The count of reported events for the bucket.
101
- */
102
- export async function countReported(db: Db.Db, key: BucketKey): Promise<number> {
103
- const row = await db.kysely
104
- .selectFrom('request_usage_meter_events')
105
- .select((eb) => eb.fn.countAll<string>().as('count'))
106
- .where('orgId', '=', key.orgId)
107
- .where('environment', '=', key.environment)
108
- .where('bucketStart', '=', key.bucketStart)
109
- .where('state', '=', 'reported')
110
- .executeTakeFirstOrThrow()
111
- return Number(row.count)
112
- }
113
-
114
94
  /**
115
95
  * Inserts a frozen `pending` meter event, claiming a delta. Idempotent on the
116
96
  * deterministic `identifier`: a re-seal after a crash resolves to the existing
@@ -157,7 +137,7 @@ export declare namespace insertPending {
157
137
  identifier: string
158
138
  /** Organization id (`org_…`). */
159
139
  orgId: string
160
- /** Delta sequence within the bucket. */
140
+ /** Reported-count watermark observed when this delta was claimed. */
161
141
  sequence: number
162
142
  /** Stripe customer id the event bills against. */
163
143
  stripeCustomerId: string
@@ -227,5 +207,5 @@ function toBucket(row: Selectable<BucketTable>): Bucket {
227
207
 
228
208
  /** Maps a meter-event row to its domain record. */
229
209
  function toMeterEvent(row: Selectable<MeterEventTable>): MeterEvent {
230
- return { ...row, deltaCount: Number(row.deltaCount) }
210
+ return { ...row, deltaCount: Number(row.deltaCount), sequence: Number(row.sequence) }
231
211
  }
@@ -189,6 +189,47 @@ describe('assignTransactionHash', () => {
189
189
  )
190
190
  await db.close()
191
191
  })
192
+
193
+ test('behavior: keeps an over-limit failed intent retryable across a period rollover', async () => {
194
+ const db = create()
195
+ await db.migrate()
196
+ const { transactionHash: _, ...intent } = input
197
+ vi.useFakeTimers({ now: new Date('2026-01-31T23:59:00.000Z') })
198
+ try {
199
+ const failed = await SponsoredTransactions.upsert(db, { ...intent, feeMax: '100' })
200
+ await SponsoredTransactions.fail(db, failed.id, new Date().toISOString())
201
+ vi.setSystemTime(new Date('2026-02-01T00:01:00.000Z'))
202
+ await SponsoredTransactions.upsert(db, {
203
+ ...intent,
204
+ feeMax: '100',
205
+ signPayload: `0x${'bc'.repeat(32)}`,
206
+ })
207
+
208
+ await expect(
209
+ SponsoredTransactions.assignTransactionHash(db, failed.id, input.transactionHash, {
210
+ chainIds: [input.chainId],
211
+ max: 100n,
212
+ since: '2026-02-01T00:00:00.000Z',
213
+ }),
214
+ ).rejects.toBeInstanceOf(SponsoredTransactions.PeriodSpendLimitError)
215
+ expect(await SponsoredTransactions.get(db, failed.id)).toMatchObject({
216
+ createdAt: '2026-01-31T23:59:00.000Z',
217
+ finalizedAt: '2026-02-01T00:01:00.000Z',
218
+ status: 'failed',
219
+ transactionHash: null,
220
+ })
221
+ expect(
222
+ (
223
+ await SponsoredTransactions.listIntents(db, {
224
+ failedSince: '2026-02-01T00:00:00.000Z',
225
+ })
226
+ ).map((record) => record.id),
227
+ ).toContain(failed.id)
228
+ } finally {
229
+ vi.useRealTimers()
230
+ await db.close()
231
+ }
232
+ })
192
233
  })
193
234
 
194
235
  describe('finalize', () => {
@@ -182,19 +182,58 @@ export declare namespace listIntents {
182
182
  * @param db - The database.
183
183
  * @param id - The sponsored-transaction id (`stx_…`).
184
184
  * @param transactionHash - The matched on-chain transaction hash.
185
+ * @param limit - The active period spend limit, when enforced.
185
186
  */
186
187
  export async function assignTransactionHash(
187
188
  db: Db.Db,
188
189
  id: string,
189
190
  transactionHash: string,
191
+ limit?: reserve.Limit,
190
192
  ): Promise<void> {
191
- await db.kysely
192
- .updateTable('sponsored_transactions')
193
- .set({ finalizedAt: null, status: 'pending', transactionHash })
194
- .where('id', '=', id)
195
- .where('status', 'in', ['failed', 'pending'])
196
- .where('transactionHash', 'is', null)
197
- .execute()
193
+ try {
194
+ await db.transaction(async (tx) => {
195
+ const record = await get(tx, id)
196
+ if (
197
+ !record ||
198
+ record.transactionHash !== null ||
199
+ !['failed', 'pending'].includes(record.status)
200
+ )
201
+ return
202
+ if (record.status === 'failed' && record.billable && limit)
203
+ await sql`select pg_advisory_xact_lock(${billingLockClass}, hashtext(${record.orgId}))`.execute(tx.kysely) // prettier-ignore
204
+ await tx.kysely
205
+ .updateTable('sponsored_transactions')
206
+ .set({
207
+ ...(record.status === 'failed' ? { createdAt: new Date().toISOString() } : {}),
208
+ finalizedAt: null,
209
+ status: 'pending',
210
+ transactionHash,
211
+ })
212
+ .where('id', '=', id)
213
+ .where('status', '=', record.status)
214
+ .where('transactionHash', 'is', null)
215
+ .execute()
216
+ if (record.status === 'failed' && record.billable && limit) {
217
+ const committed = await spend(tx, {
218
+ chainIds: limit.chainIds,
219
+ orgId: record.orgId,
220
+ since: limit.since,
221
+ })
222
+ if (committed > limit.max) throw new PeriodSpendLimitError()
223
+ }
224
+ })
225
+ } catch (error) {
226
+ // Keep the on-chain match eligible until budget becomes available.
227
+ if (error instanceof PeriodSpendLimitError)
228
+ await db.kysely
229
+ .updateTable('sponsored_transactions')
230
+ .set({ finalizedAt: new Date().toISOString() })
231
+ .where('id', '=', id)
232
+ .where('status', '=', 'failed')
233
+ .where('transactionHash', 'is', null)
234
+ .execute()
235
+ throw error
236
+ }
198
237
  }
199
238
 
200
239
  /**
@@ -743,7 +743,7 @@ describe('require', () => {
743
743
  })
744
744
 
745
745
  describe('mpp', () => {
746
- test('uses a default paid-request limit of 100 per minute', async () => {
746
+ test('uses a default paid-request limit with a normalized IPv6 identity', async () => {
747
747
  const rateLimit = underQuota()
748
748
  const consume = vi.spyOn(rateLimit, 'consume')
749
749
  const app = createApp({
@@ -757,7 +757,7 @@ describe('require', () => {
757
757
  const response = await app.request('/protected', {
758
758
  headers: {
759
759
  authorization: 'Payment paid',
760
- 'x-forwarded-for': '203.0.113.10',
760
+ 'cf-connecting-ip': '2001:db8:abcd:12::1',
761
761
  },
762
762
  })
763
763
 
@@ -765,7 +765,60 @@ describe('require', () => {
765
765
  expect(response.headers.get('RateLimit-Limit')).toBe('100')
766
766
  expect(response.headers.get('RateLimit-Scope')).toBe('mpp')
767
767
  expect(consume).toHaveBeenCalledWith({
768
- key: 'mpp:public:203.0.113.10',
768
+ key: 'mpp:public:2001:db8:abcd:12',
769
+ limit: { perMinute: 100 },
770
+ })
771
+ })
772
+
773
+ test('ignores untrusted forwarding headers for public identity', 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: { mpp: { session: { amount: '1' } } },
783
+ })
784
+ const response = await app.request('/protected', {
785
+ headers: {
786
+ authorization: 'Payment paid',
787
+ 'x-forwarded-for': '203.0.113.10',
788
+ 'x-real-ip': '203.0.113.11',
789
+ 'x-test-client-ip': '203.0.113.12',
790
+ },
791
+ })
792
+
793
+ expect(response.status).toBe(200)
794
+ expect(consume).toHaveBeenCalledWith({
795
+ key: 'mpp:public:anonymous',
796
+ limit: { perMinute: 100 },
797
+ })
798
+ })
799
+
800
+ test('uses a configured trusted client-IP resolver outside Cloudflare', async () => {
801
+ const rateLimit = underQuota()
802
+ const consume = vi.spyOn(rateLimit, 'consume')
803
+ const app = createApp({
804
+ auth: {
805
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
806
+ mpp: createMppHandler(),
807
+ publicClientIp: (request) => request.headers.get('x-real-ip') ?? undefined,
808
+ rateLimit,
809
+ },
810
+ policy: { mpp: { session: { amount: '1' } } },
811
+ })
812
+ const response = await app.request('/protected', {
813
+ headers: {
814
+ authorization: 'Payment paid',
815
+ 'x-real-ip': '2001:db8:abcd:12::99',
816
+ },
817
+ })
818
+
819
+ expect(response.status).toBe(200)
820
+ expect(consume).toHaveBeenCalledWith({
821
+ key: 'mpp:public:2001:db8:abcd:12',
769
822
  limit: { perMinute: 100 },
770
823
  })
771
824
  })
@@ -788,7 +841,7 @@ describe('require', () => {
788
841
  headers: {
789
842
  authorization: 'Payment paid',
790
843
  'tempo-api-key': token,
791
- 'x-forwarded-for': '203.0.113.10',
844
+ 'cf-connecting-ip': '203.0.113.10',
792
845
  },
793
846
  })
794
847
 
@@ -1228,7 +1281,7 @@ describe('require', () => {
1228
1281
  policy: { public: { rateLimit: { perMinute: 60 } } },
1229
1282
  })
1230
1283
  const response = await app.request('/protected', {
1231
- headers: { 'x-forwarded-for': '203.0.113.10' },
1284
+ headers: { 'cf-connecting-ip': '203.0.113.10' },
1232
1285
  })
1233
1286
  const body = await response.json()
1234
1287
 
@@ -1280,7 +1333,7 @@ describe('require', () => {
1280
1333
  policy: { public: { rateLimit: { perMinute: 1 } } },
1281
1334
  })
1282
1335
  const response = await app.request('/protected', {
1283
- headers: { 'x-forwarded-for': '203.0.113.10' },
1336
+ headers: { 'cf-connecting-ip': '203.0.113.10' },
1284
1337
  })
1285
1338
  const body = await response.json()
1286
1339
 
@@ -1338,7 +1391,7 @@ describe('require', () => {
1338
1391
  policy: { public: { rateLimit: { perMinute: 1 } } },
1339
1392
  })
1340
1393
  const pending = app.request('/protected', {
1341
- headers: { 'x-forwarded-for': '203.0.113.10' },
1394
+ headers: { 'cf-connecting-ip': '203.0.113.10' },
1342
1395
  })
1343
1396
  // Advance past the 300ms consume timeout so it fails open.
1344
1397
  await vi.advanceTimersByTimeAsync(300)
@@ -1382,7 +1435,7 @@ describe('require', () => {
1382
1435
  policy: { public: { rateLimit: { perMinute: 1 } } },
1383
1436
  })
1384
1437
  const response = await app.request('/protected', {
1385
- headers: { 'x-forwarded-for': '203.0.113.10' },
1438
+ headers: { 'cf-connecting-ip': '203.0.113.10' },
1386
1439
  })
1387
1440
  const body = await response.json()
1388
1441
 
@@ -1419,7 +1472,7 @@ describe('require', () => {
1419
1472
  policy: { public: { rateLimit: { perMinute: 1 } } },
1420
1473
  })
1421
1474
  const response = await app.request('/protected', {
1422
- headers: { 'x-forwarded-for': '203.0.113.10' },
1475
+ headers: { 'cf-connecting-ip': '203.0.113.10' },
1423
1476
  })
1424
1477
 
1425
1478
  expect(response.status).toMatchInlineSnapshot(`200`)
@@ -1448,7 +1501,7 @@ describe('require', () => {
1448
1501
  policy: { public: { rateLimit: { perMinute: 1 } } },
1449
1502
  })
1450
1503
  const response = await app.request('/protected', {
1451
- headers: { 'x-forwarded-for': '203.0.113.10' },
1504
+ headers: { 'cf-connecting-ip': '203.0.113.10' },
1452
1505
  })
1453
1506
 
1454
1507
  expect(response.status).toMatchInlineSnapshot(`200`)
@@ -58,6 +58,8 @@ export type Context = {
58
58
  * framework default.
59
59
  */
60
60
  publicRateLimit?: RateLimit.Limit | undefined
61
+ /** Resolves a trusted client IP for public quota identity. */
62
+ publicClientIp?: ((request: Request) => string | undefined) | undefined
61
63
  /** Rate-limit store used by API handlers. */
62
64
  rateLimit?: RateLimit.Store | undefined
63
65
  /** Session lane resolution (the session surface's capability), or undefined when sessions are unmounted. */
@@ -523,6 +525,9 @@ export function middleware<
523
525
  // API-key-only routes (no per-route `policy.public`) still fall back to the
524
526
  // deployment's configured public quota.
525
527
  publicRateLimit: defaults.public.rateLimit,
528
+ ...(options.public && options.public.clientIp
529
+ ? { publicClientIp: options.public.clientIp }
530
+ : {}),
526
531
  ...(options.mpp === false
527
532
  ? {}
528
533
  : {
@@ -608,6 +613,8 @@ export declare namespace middleware {
608
613
  /** Default public access policy. Pass false to disable anonymous access. */
609
614
  public?:
610
615
  | {
616
+ /** Resolves the client IP from trusted runtime or proxy metadata. Defaults to `cf-connecting-ip`. */
617
+ clientIp?: ((request: Request) => string | undefined) | undefined
611
618
  /** Anonymous public quota for protected routes. */
612
619
  rateLimit?: RateLimit.Limit | undefined
613
620
  }
@@ -1567,7 +1574,7 @@ export function require<environment extends Environment = Environment>(
1567
1574
  }
1568
1575
  }
1569
1576
 
1570
- const publicPrincipal = principalFromPublic(c)
1577
+ const publicPrincipal = principalFromPublic(c, auth)
1571
1578
  if (hasPayment) {
1572
1579
  c.set('principal', publicPrincipal)
1573
1580
  return requirePayment(c, proceed, {
@@ -1901,15 +1908,10 @@ function principalFromApiKey(apiKey: ApiKey.ApiKey): Principal {
1901
1908
  }
1902
1909
  }
1903
1910
 
1904
- function principalFromPublic(c: hono_Context): Principal {
1905
- const forwarded = c.req.header('x-forwarded-for')?.split(',')[0]?.trim()
1911
+ function principalFromPublic(c: hono_Context, auth: Context): Principal {
1912
+ const ip = auth.publicClientIp ? auth.publicClientIp(c.req.raw) : c.req.header('cf-connecting-ip')
1906
1913
  return {
1907
- id:
1908
- c.req.header('cf-connecting-ip') ??
1909
- c.req.header('x-real-ip') ??
1910
- forwarded ??
1911
- c.req.header('x-test-client-ip') ??
1912
- 'anonymous',
1914
+ id: ip ? RateLimit.normalizeIp(ip) : 'anonymous',
1913
1915
  type: 'public',
1914
1916
  }
1915
1917
  }
@@ -1,6 +1,25 @@
1
1
  import { Hono } from 'hono'
2
+ import * as z from 'zod/mini'
2
3
  import * as Cache from './Cache.js'
3
4
 
5
+ describe('urlKey', () => {
6
+ test('serializes parsed arrays as repeated query parameters', async () => {
7
+ const schema = z.object({ include: z.array(z.string()) })
8
+ const app = new Hono<{ Variables: { chainId: number } }>()
9
+ app.use('*', async (c, next) => {
10
+ c.set('chainId', 4217)
11
+ await next()
12
+ })
13
+ app.get('/', (c) => c.text(Cache.urlKey(c, schema)))
14
+
15
+ const left = await (await app.request('/?include=a%2Cb&include=c')).text()
16
+ const right = await (await app.request('/?include=a&include=b%2Cc')).text()
17
+
18
+ expect(left).toMatchInlineSnapshot(`"http://localhost/?chainId=4217&include=a%2Cb&include=c"`)
19
+ expect(right).toMatchInlineSnapshot(`"http://localhost/?chainId=4217&include=a&include=b%2Cc"`)
20
+ })
21
+ })
22
+
4
23
  describe('response', () => {
5
24
  test('preserves rate-limit headers without caching them', async () => {
6
25
  await withCaches(async () => {
@@ -192,11 +192,23 @@ export function urlKey<
192
192
  schema extends z.ZodMiniType,
193
193
  >(c: Context<environment>, schema: schema): string {
194
194
  const url = new URL(c.req.url)
195
- const query = schema.parse(Object.fromEntries(url.searchParams)) as Record<string, unknown>
195
+ const query = schema.parse(
196
+ Object.fromEntries(
197
+ Object.entries(c.req.queries()).map(([key, values]) => [
198
+ key,
199
+ values.length === 1 ? values[0] : values,
200
+ ]),
201
+ ),
202
+ ) as Record<string, unknown>
196
203
  url.search = ''
197
204
  if (query['chainId'] === undefined) url.searchParams.set('chainId', String(c.get('chainId')))
198
205
  for (const [key, value] of Object.entries(query)) {
199
206
  if (value === undefined) continue
207
+ if (Array.isArray(value)) {
208
+ for (const item of value)
209
+ url.searchParams.append(key, String(item as number | string | boolean))
210
+ continue
211
+ }
200
212
  url.searchParams.set(key, String(value as number | string | boolean))
201
213
  }
202
214
  return url.toString()
@@ -130,6 +130,33 @@ describe('middleware', () => {
130
130
  expect(store.size).toMatchInlineSnapshot(`0`)
131
131
  })
132
132
 
133
+ test('strips payment headers from private responses before sharing them', async () => {
134
+ const { app, store, downstreamCalls } = createApp()
135
+
136
+ const miss = await app.request('/private')
137
+ const hit = await app.request('/private')
138
+
139
+ expect(downstreamCalls()).toMatchInlineSnapshot(`1`)
140
+ expect(store.size).toMatchInlineSnapshot(`1`)
141
+ expect(miss.headers.get('payment-receipt')).toMatchInlineSnapshot(`"payer-specific"`)
142
+ expect(miss.headers.get('payment-session-snapshot')).toMatchInlineSnapshot(`"session-specific"`)
143
+ expect(hit.headers.get('payment-receipt')).toMatchInlineSnapshot(`null`)
144
+ expect(hit.headers.get('payment-session-snapshot')).toMatchInlineSnapshot(`null`)
145
+ expect(hit.headers.get('cache-control')).toMatchInlineSnapshot(
146
+ `"public, max-age=30, s-maxage=30"`,
147
+ )
148
+ })
149
+
150
+ test('does not cache responses marked `no-store`', async () => {
151
+ const { app, store, downstreamCalls } = createApp()
152
+
153
+ await app.request('/no-store')
154
+ await app.request('/no-store')
155
+
156
+ expect(downstreamCalls()).toMatchInlineSnapshot(`2`)
157
+ expect(store.size).toMatchInlineSnapshot(`0`)
158
+ })
159
+
133
160
  test('does not cache routes that did not opt in', async () => {
134
161
  const { app, store, downstreamCalls } = createApp()
135
162
 
@@ -271,6 +298,19 @@ function createApp(
271
298
  c.set('edgeCache', { maxAge: 30, staleWhileRevalidate: 300 })
272
299
  return c.json({ ok: false }, 500)
273
300
  })
301
+ app.get('/private', cacheable, (c) => {
302
+ c.set('edgeCache', { maxAge: 30 })
303
+ c.header('Cache-Control', 'private, max-age=30')
304
+ c.header('Payment-Receipt', 'payer-specific')
305
+ c.header('Payment-Session-Snapshot', 'session-specific')
306
+ return c.json({ ok: true })
307
+ })
308
+ app.get('/no-store', cacheable, (c) => {
309
+ c.set('edgeCache', { maxAge: 30 })
310
+ c.header('Cache-Control', 'no-store')
311
+ c.header('Payment-Receipt', 'payer-specific')
312
+ return c.json({ ok: true })
313
+ })
274
314
  app.get('/binary', cacheable, (c) => {
275
315
  c.set('edgeCache', { maxAge: 30, staleWhileRevalidate: 300 })
276
316
  // Bytes that are not valid UTF-8 (PNG signature) — corrupted if stored via
@@ -33,12 +33,16 @@ const volatileHeaders = [
33
33
  'cf-cache-status',
34
34
  'content-encoding',
35
35
  'content-length',
36
- 'server-timing',
37
- 'set-cookie',
36
+ 'payment-receipt',
37
+ 'payment-session-snapshot',
38
38
  'ratelimit-limit',
39
39
  'ratelimit-remaining',
40
40
  'ratelimit-reset',
41
+ 'ratelimit-scope',
42
+ 'server-timing',
43
+ 'set-cookie',
41
44
  'tempo-request-id',
45
+ 'www-authenticate',
42
46
  ]
43
47
 
44
48
  /**
@@ -115,6 +119,8 @@ export function middleware<environment extends Env>(
115
119
  if (c.res.status < 200 || c.res.status >= 300) return
116
120
  if (c.res.headers.has('set-cookie')) return
117
121
  if (c.res.headers.get('content-type')?.includes('text/event-stream')) return
122
+ const responseDirectives = (c.res.headers.get('cache-control') ?? '').toLowerCase()
123
+ if (responseDirectives.includes('no-cache') || responseDirectives.includes('no-store')) return
118
124
 
119
125
  // Store a public, sanitized copy; the client still receives the original
120
126
  // (private, metered) response for this miss. Clone synchronously — the
@@ -247,7 +253,9 @@ function isTextual(contentType: string | null): boolean {
247
253
  function cacheKey(requestUrl: string) {
248
254
  const url = new URL(requestUrl)
249
255
  url.searchParams.sort()
250
- return url.toString()
256
+ // Version the key so entries written before a sanitizer change cannot
257
+ // survive the deployment that changes the shared-cache boundary.
258
+ return `edge:v2:${url.toString()}`
251
259
  }
252
260
 
253
261
  function getWaitUntil(c: { executionCtx?: { waitUntil(promise: Promise<unknown>): void } }) {
@@ -235,6 +235,32 @@ describe('listQuoteCandidates', () => {
235
235
 
236
236
  expect(candidate?.id).toBe('tron-usdt-tempo-usdt0-rhino')
237
237
  })
238
+
239
+ test('selects proven Squid stablecoin corridors', () => {
240
+ const candidates = [
241
+ FundingQuotes.listQuoteCandidates({
242
+ destinationToken: 'USDC.e',
243
+ provider: 'squid',
244
+ sourceAmount: '1000000',
245
+ sourceAmountUnits: 'baseUnits',
246
+ sourceChain: 'base',
247
+ sourceToken: 'USDC',
248
+ })[0],
249
+ FundingQuotes.listQuoteCandidates({
250
+ destinationToken: 'USDT0',
251
+ provider: 'squid',
252
+ sourceAmount: '1000000',
253
+ sourceAmountUnits: 'baseUnits',
254
+ sourceChain: 'ethereum',
255
+ sourceToken: 'USDT',
256
+ })[0],
257
+ ]
258
+
259
+ expect(candidates.map((candidate) => candidate?.id)).toEqual([
260
+ 'base-usdc-tempo-usdce-squid',
261
+ 'ethereum-usdt-tempo-usdt0-squid',
262
+ ])
263
+ })
238
264
  })
239
265
 
240
266
  describe('listProviders', () => {
@@ -262,6 +288,13 @@ describe('listProviders', () => {
262
288
  "requiresApiKey": false,
263
289
  "status": "unconfigured",
264
290
  },
291
+ {
292
+ "id": "squid",
293
+ "kind": "solverNetwork",
294
+ "name": "Squid",
295
+ "requiresApiKey": true,
296
+ "status": "unconfigured",
297
+ },
265
298
  ]
266
299
  `)
267
300
  })
@@ -245,6 +245,13 @@ const providers = [
245
245
  requiresApiKey: false,
246
246
  status: 'unconfigured',
247
247
  },
248
+ {
249
+ id: 'squid',
250
+ kind: 'solverNetwork',
251
+ name: 'Squid',
252
+ requiresApiKey: true,
253
+ status: 'unconfigured',
254
+ },
248
255
  ] as const
249
256
 
250
257
  type TokenInput = (typeof sourceChains)[number]['tokens'][number]
@@ -369,7 +376,9 @@ const inventory: readonly InventoryEntry[] = [
369
376
  { destinationToken: 'usdce', provider: 'across', sourceChains: ['ethereum', 'base', 'bnb'], sourceTokens: ['USDC'] }, // prettier-ignore
370
377
  { destinationToken: 'usdce', provider: 'relay', sourceChains: ['ethereum', 'base', 'bnb'], sourceTokens: ['USDC', 'USDT'] }, // prettier-ignore
371
378
  { destinationToken: 'usdce', provider: 'rhino', sourceChains: ['base', 'solana'], sourceTokens: ['USDC'] }, // prettier-ignore
379
+ { destinationToken: 'usdce', provider: 'squid', sourceChains: ['ethereum', 'base', 'bnb'], sourceTokens: ['USDC'] }, // prettier-ignore
372
380
  { destinationToken: 'usdt0', provider: 'rhino', sourceChains: ['tron'], sourceTokens: ['USDT'] }, // prettier-ignore
381
+ { destinationToken: 'usdt0', provider: 'squid', sourceChains: ['ethereum', 'base', 'bnb'], sourceTokens: ['USDT'] }, // prettier-ignore
373
382
  ]
374
383
 
375
384
  type InventoryEntry = {
@@ -130,6 +130,18 @@ describe('edge', () => {
130
130
  expect(ip.limit).toHaveBeenCalledWith({ key: '2001:db8:abcd:12' })
131
131
  })
132
132
 
133
+ test('normalizes equivalent IPv6 addresses to the same /64 prefix', () => {
134
+ expect([
135
+ RateLimit.normalizeIp('2001:0db8:abcd:0012::1'),
136
+ RateLimit.normalizeIp('2001:db8:abcd:12:ffff::2'),
137
+ ]).toMatchInlineSnapshot(`
138
+ [
139
+ "2001:db8:abcd:12",
140
+ "2001:db8:abcd:12",
141
+ ]
142
+ `)
143
+ })
144
+
133
145
  test('returns 429 when any layer denies', async () => {
134
146
  const response = await RateLimit.edge(request({ asn: 64512, ip: '203.0.113.7' }), {
135
147
  asn: binding(false),
@@ -139,12 +139,12 @@ type CfProperties = { asn?: number | undefined }
139
139
  function clientKey(request: Request): string {
140
140
  const ip = request.headers.get('cf-connecting-ip')
141
141
  if (!ip) return 'unknown'
142
- if (ip.includes('.')) return ip
143
- return ipv6Prefix(ip)
142
+ return normalizeIp(ip)
144
143
  }
145
144
 
146
- /** First four hextets of an IPv6 address, with `::` expanded. */
147
- function ipv6Prefix(ip: string): string {
145
+ /** Normalizes an IPv4 address or IPv6 /64 prefix for per-client quotas. */
146
+ export function normalizeIp(ip: string): string {
147
+ if (ip.includes('.')) return ip
148
148
  const [address = ''] = ip.split('%')
149
149
  const [head = '', tail = ''] = address.split('::')
150
150
  const left = head ? head.split(':') : []