tapimo 0.5.1 → 0.5.3
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/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/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/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/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/mcp.d.ts +9 -2
- package/dist/apps/mcp.d.ts.map +1 -1
- package/dist/apps/mcp.js +9 -4
- package/dist/apps/mcp.js.map +1 -1
- 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/cli/Cli.d.ts.map +1 -1
- package/dist/cli/Cli.js +7 -2
- package/dist/cli/Cli.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 +7 -2
- 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/mcp.test.ts +13 -0
- package/src/apps/mcp.ts +18 -5
- package/src/apps/relay/Sponsorships.ts +24 -1
- package/src/cli/Cli.test.ts +36 -0
- package/src/cli/Cli.ts +7 -2
- 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
package/src/cli/Cli.test.ts
CHANGED
|
@@ -16,6 +16,14 @@ describe('from', () => {
|
|
|
16
16
|
|
|
17
17
|
expect(cli.name).toMatchInlineSnapshot(`"tapimo"`)
|
|
18
18
|
expect(cli.description).toMatchInlineSnapshot(`"Tempo API CLI"`)
|
|
19
|
+
expect((await initialize(cli)).result).toMatchObject({
|
|
20
|
+
instructions: expect.stringContaining('Tempo MCP provides access'),
|
|
21
|
+
serverInfo: {
|
|
22
|
+
name: 'tempo',
|
|
23
|
+
title: 'Tempo MCP',
|
|
24
|
+
version: '1.2.3',
|
|
25
|
+
},
|
|
26
|
+
})
|
|
19
27
|
} finally {
|
|
20
28
|
fetch.mockRestore()
|
|
21
29
|
}
|
|
@@ -91,6 +99,34 @@ function mockHostedApi(app = TestApp.create()) {
|
|
|
91
99
|
})
|
|
92
100
|
}
|
|
93
101
|
|
|
102
|
+
async function initialize(cli: ReturnType<typeof Cli.from>) {
|
|
103
|
+
const response = await cli.fetch(
|
|
104
|
+
new Request('http://localhost/mcp', {
|
|
105
|
+
body: JSON.stringify({
|
|
106
|
+
id: 1,
|
|
107
|
+
jsonrpc: '2.0',
|
|
108
|
+
method: 'initialize',
|
|
109
|
+
params: {
|
|
110
|
+
capabilities: {},
|
|
111
|
+
clientInfo: { name: 'test-client', version: '1.0.0' },
|
|
112
|
+
protocolVersion: '2025-03-26',
|
|
113
|
+
},
|
|
114
|
+
}),
|
|
115
|
+
headers: {
|
|
116
|
+
accept: 'application/json, text/event-stream',
|
|
117
|
+
'content-type': 'application/json',
|
|
118
|
+
},
|
|
119
|
+
method: 'POST',
|
|
120
|
+
}),
|
|
121
|
+
)
|
|
122
|
+
return (await response.json()) as {
|
|
123
|
+
result: {
|
|
124
|
+
instructions: string
|
|
125
|
+
serverInfo: { name: string; title: string; version: string }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
94
130
|
async function serve(cli: ReturnType<typeof Cli.from>, argv: string[]) {
|
|
95
131
|
const output: string[] = []
|
|
96
132
|
await cli.serve(argv, {
|
package/src/cli/Cli.ts
CHANGED
|
@@ -8,7 +8,7 @@ const excludedTools = ['admin_*', 'gecko_*', 'v1_auth_*']
|
|
|
8
8
|
|
|
9
9
|
/** Usage guidance surfaced to MCP clients on initialize (`<cli> --mcp`). */
|
|
10
10
|
const instructions =
|
|
11
|
-
'
|
|
11
|
+
'Tempo MCP provides access to the hosted Tempo API and documentation. Authenticated tools accept an `authorization` input (`Bearer <api key>`). List endpoints paginate via `cursor`/`nextCursor`. `docs_*` tools search the Tempo documentation.'
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Creates an incur CLI backed by a hosted Tempo API, plus the super admin
|
|
@@ -28,7 +28,12 @@ export function from(url: string | URL, options: from.Options = {}) {
|
|
|
28
28
|
.create(name, {
|
|
29
29
|
description,
|
|
30
30
|
fetch: Fetch.fromRequest(url, request),
|
|
31
|
-
mcp: {
|
|
31
|
+
mcp: {
|
|
32
|
+
instructions,
|
|
33
|
+
name: 'tempo',
|
|
34
|
+
title: 'Tempo MCP',
|
|
35
|
+
tools: { exclude: excludedTools },
|
|
36
|
+
},
|
|
32
37
|
openapi,
|
|
33
38
|
openapiConfig: options.openapiConfig ?? { mode: 'namespace' },
|
|
34
39
|
version,
|
package/src/db/Db.ts
CHANGED
|
@@ -641,6 +641,10 @@ export const migrations: readonly Migration[] = [
|
|
|
641
641
|
.on('inviteLinkRedemptions')
|
|
642
642
|
.columns(['inviteLinkId', 'createdAt']),
|
|
643
643
|
},
|
|
644
|
+
{
|
|
645
|
+
name: '0058_request_usage_sequence_bigint',
|
|
646
|
+
up: (db) => raw(db, sql`ALTER TABLE request_usage_meter_events ALTER COLUMN sequence TYPE bigint`), // prettier-ignore
|
|
647
|
+
},
|
|
644
648
|
]
|
|
645
649
|
|
|
646
650
|
/**
|
package/src/db/Schema.ts
CHANGED
|
@@ -272,7 +272,7 @@ export const RequestUsageMeterEvent = z.object({
|
|
|
272
272
|
.check(z.describe('When Stripe acknowledged the event (ISO 8601), or null.')),
|
|
273
273
|
sequence: z
|
|
274
274
|
.number()
|
|
275
|
-
.check(z.describe('
|
|
275
|
+
.check(z.describe('Reported-count watermark observed when this delta was claimed.')),
|
|
276
276
|
state: z
|
|
277
277
|
.enum(['pending', 'reported', 'skipped'])
|
|
278
278
|
.check(z.describe('Exactly-once state: `pending` retries, `reported` is done, `skipped` is audited.')), // prettier-ignore
|
|
@@ -4,6 +4,8 @@ import * as Runtime from '../../../test/runtime.js'
|
|
|
4
4
|
import * as Webhooks from '../../internal/Webhooks.js'
|
|
5
5
|
import * as Db from '../Db.js'
|
|
6
6
|
import * as Organizations from './organizations.js'
|
|
7
|
+
import * as SponsoredTransactions from './sponsoredTransactions.js'
|
|
8
|
+
import * as StripeCustomers from './stripeCustomers.js'
|
|
7
9
|
|
|
8
10
|
const create = () => Db.postgres({ connectionString: Runtime.postgresUrl, schema: `t_${nanoid()}` })
|
|
9
11
|
|
|
@@ -127,6 +129,34 @@ describe('upsert', () => {
|
|
|
127
129
|
})
|
|
128
130
|
|
|
129
131
|
describe('deleteOrganization', () => {
|
|
132
|
+
test('behavior: preserves an org with unreported billable sponsorships', async () => {
|
|
133
|
+
const db = create()
|
|
134
|
+
await db.migrate()
|
|
135
|
+
await Organizations.create(db, { id: 'org_1', name: 'One' })
|
|
136
|
+
await StripeCustomers.create(db, {
|
|
137
|
+
orgId: 'org_1',
|
|
138
|
+
stripeCustomerId: 'cus_org_1',
|
|
139
|
+
})
|
|
140
|
+
await SponsoredTransactions.upsert(db, {
|
|
141
|
+
apiKeyId: 'key_1',
|
|
142
|
+
billable: true,
|
|
143
|
+
chainId: 4217,
|
|
144
|
+
environment: 'production',
|
|
145
|
+
orgId: 'org_1',
|
|
146
|
+
projectId: 'prj_1',
|
|
147
|
+
signPayload: `0x${'bb'.repeat(32)}`,
|
|
148
|
+
transaction: `0x76${'cc'.repeat(16)}`,
|
|
149
|
+
transactionHash: `0x${'aa'.repeat(32)}`,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
await expect(Organizations.deleteOrganization(db, 'org_1')).rejects.toThrow(
|
|
153
|
+
Organizations.UnreportedSponsorshipsError,
|
|
154
|
+
)
|
|
155
|
+
expect(await Organizations.get(db, 'org_1')).toBeDefined()
|
|
156
|
+
expect(await StripeCustomers.get(db, 'org_1')).toBeDefined()
|
|
157
|
+
await db.close()
|
|
158
|
+
})
|
|
159
|
+
|
|
130
160
|
test('behavior: deletes api-key-owned webhook subscriptions', async () => {
|
|
131
161
|
const db = create()
|
|
132
162
|
await db.migrate()
|
|
@@ -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
|