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.
- package/dist/App.d.ts +34 -18
- package/dist/App.d.ts.map +1 -1
- package/dist/admin/apps/verified-tokens.d.ts +117 -117
- package/dist/apps/data/App.d.ts +3217 -3217
- package/dist/apps/data/FundingQuoteProviders.d.ts +32 -0
- package/dist/apps/data/FundingQuoteProviders.d.ts.map +1 -1
- package/dist/apps/data/FundingQuoteProviders.js +134 -1
- package/dist/apps/data/FundingQuoteProviders.js.map +1 -1
- package/dist/apps/data/routes/addresses.d.ts +206 -206
- package/dist/apps/data/routes/balances.d.ts +46 -46
- package/dist/apps/data/routes/coingecko.d.ts +169 -169
- package/dist/apps/data/routes/funding.d.ts +8 -2
- package/dist/apps/data/routes/funding.d.ts.map +1 -1
- package/dist/apps/data/routes/funding.js +4 -4
- package/dist/apps/data/routes/funding.js.map +1 -1
- package/dist/apps/data/routes/indexer.d.ts +34 -34
- package/dist/apps/data/routes/receipts.d.ts +228 -228
- package/dist/apps/data/routes/rpc.d.ts +31 -31
- package/dist/apps/data/routes/transfers.d.ts +8 -15
- package/dist/apps/data/routes/transfers.d.ts.map +1 -1
- package/dist/apps/data/routes/transfers.js +21 -27
- package/dist/apps/data/routes/transfers.js.map +1 -1
- package/dist/apps/data/routes/webhooks.d.ts +35 -35
- package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
- package/dist/apps/data/routes/webhooks.js +176 -42
- package/dist/apps/data/routes/webhooks.js.map +1 -1
- package/dist/apps/management/App.d.ts +982 -966
- package/dist/apps/management/App.d.ts.map +1 -1
- package/dist/apps/management/Billing.d.ts.map +1 -1
- package/dist/apps/management/Billing.js +5 -4
- package/dist/apps/management/Billing.js.map +1 -1
- package/dist/apps/management/routes/billing.d.ts.map +1 -1
- package/dist/apps/management/routes/billing.js +1 -1
- package/dist/apps/management/routes/billing.js.map +1 -1
- package/dist/apps/management/routes/invitations.d.ts +121 -121
- package/dist/apps/management/routes/invite-links.d.ts +174 -174
- package/dist/apps/management/routes/orgs.d.ts +102 -86
- package/dist/apps/management/routes/orgs.d.ts.map +1 -1
- package/dist/apps/management/routes/orgs.js +18 -1
- package/dist/apps/management/routes/orgs.js.map +1 -1
- package/dist/apps/management/routes/projects.d.ts +124 -124
- package/dist/apps/relay/Sponsorships.d.ts +3 -0
- package/dist/apps/relay/Sponsorships.d.ts.map +1 -1
- package/dist/apps/relay/Sponsorships.js +15 -1
- package/dist/apps/relay/Sponsorships.js.map +1 -1
- package/dist/db/Db.d.ts.map +1 -1
- package/dist/db/Db.js +4 -0
- package/dist/db/Db.js.map +1 -1
- package/dist/db/Schema.js +1 -1
- package/dist/db/Schema.js.map +1 -1
- package/dist/db/tables/organizations.d.ts +6 -0
- package/dist/db/tables/organizations.d.ts.map +1 -1
- package/dist/db/tables/organizations.js +26 -0
- package/dist/db/tables/organizations.js.map +1 -1
- package/dist/db/tables/requestUsage.d.ts +2 -11
- package/dist/db/tables/requestUsage.d.ts.map +1 -1
- package/dist/db/tables/requestUsage.js +1 -20
- package/dist/db/tables/requestUsage.js.map +1 -1
- package/dist/db/tables/sponsoredTransactions.d.ts +2 -1
- package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
- package/dist/db/tables/sponsoredTransactions.js +46 -8
- package/dist/db/tables/sponsoredTransactions.js.map +1 -1
- package/dist/internal/Auth.d.ts +4 -0
- package/dist/internal/Auth.d.ts.map +1 -1
- package/dist/internal/Auth.js +7 -8
- package/dist/internal/Auth.js.map +1 -1
- package/dist/internal/Cache.d.ts.map +1 -1
- package/dist/internal/Cache.js +9 -1
- package/dist/internal/Cache.js.map +1 -1
- package/dist/internal/EdgeCache.d.ts.map +1 -1
- package/dist/internal/EdgeCache.js +12 -3
- package/dist/internal/EdgeCache.js.map +1 -1
- package/dist/internal/FundingQuotes.d.ts +16 -10
- package/dist/internal/FundingQuotes.d.ts.map +1 -1
- package/dist/internal/FundingQuotes.js +9 -0
- package/dist/internal/FundingQuotes.js.map +1 -1
- package/dist/internal/RateLimit.d.ts +2 -0
- package/dist/internal/RateLimit.d.ts.map +1 -1
- package/dist/internal/RateLimit.js +4 -4
- package/dist/internal/RateLimit.js.map +1 -1
- package/package.json +6 -1
- package/src/App.test.ts +2 -2
- package/src/Client.test-d.ts +28 -2
- package/src/apps/data/FundingQuoteProviders.test.ts +184 -0
- package/src/apps/data/FundingQuoteProviders.ts +185 -1
- package/src/apps/data/routes/funding.test.ts +7 -0
- package/src/apps/data/routes/funding.ts +4 -4
- package/src/apps/data/routes/tokens.test.ts +54 -0
- package/src/apps/data/routes/transfers.test.ts +73 -1
- package/src/apps/data/routes/transfers.ts +24 -30
- package/src/apps/data/routes/webhooks.test.ts +203 -0
- package/src/apps/data/routes/webhooks.ts +191 -54
- package/src/apps/management/Billing.test.ts +50 -1
- package/src/apps/management/Billing.ts +5 -4
- package/src/apps/management/routes/billing.ts +6 -1
- package/src/apps/management/routes/orgs.ts +19 -1
- package/src/apps/relay/Sponsorships.ts +24 -1
- package/src/db/Db.ts +4 -0
- package/src/db/Schema.ts +1 -1
- package/src/db/tables/organizations.test.ts +30 -0
- package/src/db/tables/organizations.ts +28 -0
- package/src/db/tables/requestUsage.ts +3 -23
- package/src/db/tables/sponsoredTransactions.test.ts +41 -0
- package/src/db/tables/sponsoredTransactions.ts +46 -7
- package/src/internal/Auth.test.ts +63 -10
- package/src/internal/Auth.ts +11 -9
- package/src/internal/Cache.test.ts +19 -0
- package/src/internal/Cache.ts +13 -1
- package/src/internal/EdgeCache.test.ts +40 -0
- package/src/internal/EdgeCache.ts +11 -3
- package/src/internal/FundingQuotes.test.ts +33 -0
- package/src/internal/FundingQuotes.ts +9 -0
- package/src/internal/RateLimit.test.ts +12 -0
- 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:
|
|
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
|
-
/**
|
|
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
|
-
|
|
192
|
-
.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
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
|
-
'
|
|
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:
|
|
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
|
-
'
|
|
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: { '
|
|
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: { '
|
|
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: { '
|
|
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: { '
|
|
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: { '
|
|
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: { '
|
|
1504
|
+
headers: { 'cf-connecting-ip': '203.0.113.10' },
|
|
1452
1505
|
})
|
|
1453
1506
|
|
|
1454
1507
|
expect(response.status).toMatchInlineSnapshot(`200`)
|
package/src/internal/Auth.ts
CHANGED
|
@@ -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
|
|
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 () => {
|
package/src/internal/Cache.ts
CHANGED
|
@@ -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(
|
|
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
|
-
'
|
|
37
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
return ipv6Prefix(ip)
|
|
142
|
+
return normalizeIp(ip)
|
|
144
143
|
}
|
|
145
144
|
|
|
146
|
-
/**
|
|
147
|
-
function
|
|
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(':') : []
|