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