tapimo 0.5.0 → 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 +4854 -4829
- package/dist/App.d.ts.map +1 -1
- package/dist/App.js +20 -2
- package/dist/App.js.map +1 -1
- package/dist/Assets.js +3 -3
- package/dist/Assets.js.map +1 -1
- package/dist/Scope.d.ts.map +1 -1
- package/dist/Scope.js +1 -2
- package/dist/Scope.js.map +1 -1
- package/dist/Webhooks.d.ts +1 -1
- package/dist/Webhooks.d.ts.map +1 -1
- package/dist/Webhooks.js +5 -10
- package/dist/Webhooks.js.map +1 -1
- package/dist/admin/App.d.ts +6 -34
- package/dist/admin/App.d.ts.map +1 -1
- package/dist/admin/apps/api-keys.d.ts +3 -3
- package/dist/admin/apps/api-keys.d.ts.map +1 -1
- package/dist/admin/apps/api-keys.js +4 -1
- package/dist/admin/apps/api-keys.js.map +1 -1
- package/dist/admin/apps/scopes.d.ts +6 -34
- package/dist/admin/apps/scopes.d.ts.map +1 -1
- package/dist/admin/apps/scopes.js +2 -2
- package/dist/admin/apps/scopes.js.map +1 -1
- package/dist/admin/apps/verified-tokens.d.ts +2 -2
- package/dist/admin/ui.generated.d.ts +1 -1
- package/dist/admin/ui.generated.js +33 -33
- package/dist/admin/ui.generated.js.map +1 -1
- package/dist/apps/data/App.d.ts +1398 -1405
- package/dist/apps/data/App.d.ts.map +1 -1
- package/dist/apps/data/App.js +25 -38
- package/dist/apps/data/App.js.map +1 -1
- package/dist/apps/data/FundingQuoteProviders.d.ts +108 -3
- package/dist/apps/data/FundingQuoteProviders.d.ts.map +1 -1
- package/dist/apps/data/FundingQuoteProviders.js +409 -2
- package/dist/apps/data/FundingQuoteProviders.js.map +1 -1
- package/dist/apps/data/routes/activities.d.ts +245 -245
- package/dist/apps/data/routes/blocks.d.ts +123 -123
- package/dist/apps/data/routes/funding.d.ts +236 -146
- package/dist/apps/data/routes/funding.d.ts.map +1 -1
- package/dist/apps/data/routes/funding.js +23 -12
- package/dist/apps/data/routes/funding.js.map +1 -1
- package/dist/apps/data/routes/indexer.d.ts.map +1 -1
- package/dist/apps/data/routes/indexer.js +9 -2
- package/dist/apps/data/routes/indexer.js.map +1 -1
- package/dist/apps/data/routes/rpc.d.ts +31 -31
- package/dist/apps/data/routes/tokenlist.d.ts +36 -36
- package/dist/apps/data/routes/transactions.d.ts +281 -281
- package/dist/apps/data/routes/transfers.d.ts +66 -73
- 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/verified-tokens.d.ts +86 -86
- 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 +520 -504
- 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/api-keys.d.ts +120 -120
- package/dist/apps/management/routes/billing.d.ts +211 -211
- 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 +72 -72
- package/dist/apps/management/routes/invite-links.d.ts +159 -159
- package/dist/apps/management/routes/me.d.ts +17 -17
- package/dist/apps/management/routes/members.d.ts +80 -80
- package/dist/apps/management/routes/orgs.d.ts +59 -43
- 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 +109 -109
- package/dist/apps/management/routes/scopes.d.ts +11 -11
- package/dist/apps/management/routes/usage.d.ts +79 -79
- package/dist/apps/mcp.js +2 -2
- package/dist/apps/mcp.js.map +1 -1
- package/dist/apps/relay/App.d.ts +2 -2
- package/dist/apps/relay/App.js +2 -2
- 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 +52 -10
- 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 +15 -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 +176 -56
- package/dist/internal/FundingQuotes.d.ts.map +1 -1
- package/dist/internal/FundingQuotes.js +100 -5
- package/dist/internal/FundingQuotes.js.map +1 -1
- package/dist/internal/Log.d.ts +18 -12
- package/dist/internal/Log.d.ts.map +1 -1
- package/dist/internal/Log.js +2 -0
- package/dist/internal/Log.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/dist/internal/Tidx.d.ts +9 -3
- package/dist/internal/Tidx.d.ts.map +1 -1
- package/dist/internal/Tidx.js +13 -7
- package/dist/internal/Tidx.js.map +1 -1
- package/dist/internal/Viem.d.ts +14 -1
- package/dist/internal/Viem.d.ts.map +1 -1
- package/dist/internal/Viem.js +19 -1
- package/dist/internal/Viem.js.map +1 -1
- package/package.json +7 -2
- package/src/ApiKeys.test.ts +2 -0
- package/src/App.test.ts +15 -4
- package/src/App.ts +31 -5
- package/src/Assets.ts +3 -3
- package/src/Client.test-d.ts +30 -3
- package/src/Scope.ts +4 -2
- package/src/Webhooks.ts +5 -10
- package/src/admin/apps/api-keys.test.ts +27 -0
- package/src/admin/apps/api-keys.ts +8 -1
- package/src/admin/apps/scopes.test.ts +7 -2
- package/src/admin/apps/scopes.ts +2 -2
- package/src/admin/ui/src/components/Dialog.tsx +8 -2
- package/src/admin/ui/src/lib/scopes.test.ts +19 -0
- package/src/admin/ui/src/lib/scopes.ts +12 -0
- package/src/admin/ui/src/routes/api-keys.tsx +128 -27
- package/src/admin/ui.generated.ts +33 -33
- package/src/apps/composition.test.ts +77 -0
- package/src/apps/data/App.ts +25 -45
- package/src/apps/data/FundingQuoteProviders.test.ts +615 -5
- package/src/apps/data/FundingQuoteProviders.ts +570 -4
- package/src/apps/data/routes/funding.test.ts +181 -7
- package/src/apps/data/routes/funding.ts +27 -12
- package/src/apps/data/routes/indexer.test.ts +82 -20
- package/src/apps/data/routes/indexer.ts +8 -2
- 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 +52 -3
- 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 +18 -0
- package/src/apps/mcp.ts +2 -2
- package/src/apps/relay/App.test.ts +6 -5
- package/src/apps/relay/App.ts +2 -2
- package/src/apps/relay/Sponsorships.test.ts +2 -2
- package/src/apps/relay/Sponsorships.ts +24 -1
- package/src/db/Db.test.ts +5 -5
- package/src/db/Db.ts +4 -0
- package/src/db/Schema.ts +1 -1
- package/src/db/tables/billingSettings.test.ts +2 -2
- package/src/db/tables/organizations.test.ts +32 -2
- package/src/db/tables/organizations.ts +28 -0
- package/src/db/tables/requestUsage.ts +3 -23
- package/src/db/tables/sponsoredTransactions.test.ts +60 -2
- package/src/db/tables/sponsoredTransactions.ts +67 -21
- package/src/db/tables/stripeCustomers.test.ts +2 -2
- package/src/internal/Auth.test.ts +63 -10
- package/src/internal/Auth.ts +21 -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 +130 -3
- package/src/internal/FundingQuotes.ts +113 -6
- package/src/internal/Log.ts +21 -14
- package/src/internal/RateLimit.test.ts +12 -0
- package/src/internal/RateLimit.ts +4 -4
- package/src/internal/Tidx.test.ts +48 -0
- package/src/internal/Tidx.ts +21 -7
- package/src/internal/Viem.test.ts +46 -0
- package/src/internal/Viem.ts +34 -1
|
@@ -10,10 +10,12 @@ import {
|
|
|
10
10
|
import { getTransactionReceipt } from 'viem/actions'
|
|
11
11
|
|
|
12
12
|
import * as Db from '../../db/Db.js'
|
|
13
|
+
import * as BillingSettings from '../../db/tables/billingSettings.js'
|
|
13
14
|
import * as Fees from '../../internal/Fees.js'
|
|
14
15
|
import * as SponsoredTransactions from '../../db/tables/sponsoredTransactions.js'
|
|
15
16
|
import * as Tidx from '../../internal/Tidx.js'
|
|
16
17
|
import * as Viem from '../../internal/Viem.js'
|
|
18
|
+
import * as Billing from '../management/Billing.js'
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* Runs one finalization pass over pending sponsored transactions: rows with a
|
|
@@ -177,7 +179,12 @@ export async function reconcile(db: Db.Db, options: reconcile.Options): Promise<
|
|
|
177
179
|
)
|
|
178
180
|
const intent = bySignPayload.get(signPayload)
|
|
179
181
|
if (!intent) continue
|
|
180
|
-
await SponsoredTransactions.assignTransactionHash(
|
|
182
|
+
await SponsoredTransactions.assignTransactionHash(
|
|
183
|
+
db,
|
|
184
|
+
intent.id,
|
|
185
|
+
hash,
|
|
186
|
+
await options.limitFor?.(intent),
|
|
187
|
+
)
|
|
181
188
|
bySignPayload.delete(signPayload)
|
|
182
189
|
result.matched++
|
|
183
190
|
} catch {
|
|
@@ -204,6 +211,12 @@ export declare namespace reconcile {
|
|
|
204
211
|
getClient: (chainId: number) => Client
|
|
205
212
|
/** TIDX client resolver keyed by the intent's chain id. */
|
|
206
213
|
getTidx: (chainId: number) => Tidx.Client
|
|
214
|
+
/** Resolves the current period spend limit before reopening a failed intent. */
|
|
215
|
+
limitFor?:
|
|
216
|
+
| ((
|
|
217
|
+
intent: SponsoredTransactions.Record,
|
|
218
|
+
) => Promise<SponsoredTransactions.reserve.Limit | undefined>)
|
|
219
|
+
| undefined
|
|
207
220
|
/** Lookback slack before the oldest intent's creation. @default 2 minutes */
|
|
208
221
|
skewMs?: number | undefined
|
|
209
222
|
}
|
|
@@ -270,6 +283,16 @@ export function createFinalizer(options: createFinalizer.Options): createFinaliz
|
|
|
270
283
|
feePayer,
|
|
271
284
|
getClient,
|
|
272
285
|
getTidx: (chainId) => getTidx(chainId as Viem.ChainId),
|
|
286
|
+
limitFor: async (intent) => {
|
|
287
|
+
if (!intent.billable || !Viem.isMainnet(intent.chainId)) return undefined
|
|
288
|
+
const settings = await BillingSettings.get(db, intent.orgId)
|
|
289
|
+
if (settings?.spendLimit == null) return undefined
|
|
290
|
+
return {
|
|
291
|
+
chainIds: [Viem.chainId.mainnet],
|
|
292
|
+
max: Billing.toBaseUnits(settings.spendLimit),
|
|
293
|
+
since: Billing.periodStart(settings.period),
|
|
294
|
+
}
|
|
295
|
+
},
|
|
273
296
|
})
|
|
274
297
|
: { matched: 0, scanned: 0 }
|
|
275
298
|
const finalized = await finalize(db, {
|
package/src/db/Db.test.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { nanoid } from 'nanoid'
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import * as Runtime from '../../test/runtime.js'
|
|
4
4
|
import * as Db from './Db.js'
|
|
5
5
|
import * as Organizations from './tables/organizations.js'
|
|
6
6
|
|
|
7
|
-
const create = () => Db.postgres({ connectionString: postgresUrl, schema: `t_${nanoid()}` })
|
|
7
|
+
const create = () => Db.postgres({ connectionString: Runtime.postgresUrl, schema: `t_${nanoid()}` })
|
|
8
8
|
|
|
9
9
|
describe('postgres', () => {
|
|
10
10
|
test('behavior: migrate is idempotent', async () => {
|
|
@@ -16,7 +16,7 @@ describe('postgres', () => {
|
|
|
16
16
|
})
|
|
17
17
|
|
|
18
18
|
test('behavior: creates a Postgres db without schema scoping', async () => {
|
|
19
|
-
const db = Db.postgres({ connectionString: postgresUrl })
|
|
19
|
+
const db = Db.postgres({ connectionString: Runtime.postgresUrl })
|
|
20
20
|
await db.close()
|
|
21
21
|
})
|
|
22
22
|
|
|
@@ -24,14 +24,14 @@ describe('postgres', () => {
|
|
|
24
24
|
const schema = `t_${nanoid()}`
|
|
25
25
|
// Pre-create the schema so concurrent migrate races only on the
|
|
26
26
|
// `migrations` ledger, not `CREATE SCHEMA`.
|
|
27
|
-
const seed = Db.postgres({ connectionString: postgresUrl, schema })
|
|
27
|
+
const seed = Db.postgres({ connectionString: Runtime.postgresUrl, schema })
|
|
28
28
|
await Organizations.get(seed, 'x').catch(() => {})
|
|
29
29
|
await seed.close()
|
|
30
30
|
|
|
31
31
|
// Several runners migrate the same fresh schema at once (mirrors parallel
|
|
32
32
|
// deploys). The session advisory lock serializes them; none throw.
|
|
33
33
|
const dbs = Array.from({ length: 5 }, () =>
|
|
34
|
-
Db.postgres({ connectionString: postgresUrl, schema }),
|
|
34
|
+
Db.postgres({ connectionString: Runtime.postgresUrl, schema }),
|
|
35
35
|
)
|
|
36
36
|
await Promise.all(dbs.map((db) => db.migrate()))
|
|
37
37
|
const created = await Organizations.create(dbs[0]!, { id: 'org_race', name: 'Race' })
|
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
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { nanoid } from 'nanoid'
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import * as Runtime from '../../../test/runtime.js'
|
|
4
4
|
import * as Db from '../Db.js'
|
|
5
5
|
import * as BillingSettings from './billingSettings.js'
|
|
6
6
|
|
|
7
|
-
const create = () => Db.postgres({ connectionString: postgresUrl, schema: `t_${nanoid()}` })
|
|
7
|
+
const create = () => Db.postgres({ connectionString: Runtime.postgresUrl, schema: `t_${nanoid()}` })
|
|
8
8
|
|
|
9
9
|
/** Redacts nondeterministic row fields (timestamps). */
|
|
10
10
|
function redact(record: BillingSettings.Record) {
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { nanoid } from 'nanoid'
|
|
2
2
|
|
|
3
|
+
import * as Runtime from '../../../test/runtime.js'
|
|
3
4
|
import * as Webhooks from '../../internal/Webhooks.js'
|
|
4
5
|
import * as Db from '../Db.js'
|
|
5
6
|
import * as Organizations from './organizations.js'
|
|
6
|
-
import
|
|
7
|
+
import * as SponsoredTransactions from './sponsoredTransactions.js'
|
|
8
|
+
import * as StripeCustomers from './stripeCustomers.js'
|
|
7
9
|
|
|
8
|
-
const create = () => Db.postgres({ connectionString: postgresUrl, schema: `t_${nanoid()}` })
|
|
10
|
+
const create = () => Db.postgres({ connectionString: Runtime.postgresUrl, schema: `t_${nanoid()}` })
|
|
9
11
|
|
|
10
12
|
/** Redacts nondeterministic row fields (generated nanoid ids, timestamps). */
|
|
11
13
|
function redact(record: Organizations.Record) {
|
|
@@ -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
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { nanoid } from 'nanoid'
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import * as Runtime from '../../../test/runtime.js'
|
|
4
4
|
import * as Db from '../Db.js'
|
|
5
5
|
import * as SponsoredTransactions from './sponsoredTransactions.js'
|
|
6
6
|
|
|
7
|
-
const create = () => Db.postgres({ connectionString: postgresUrl, schema: `t_${nanoid()}` })
|
|
7
|
+
const create = () => Db.postgres({ connectionString: Runtime.postgresUrl, schema: `t_${nanoid()}` })
|
|
8
8
|
|
|
9
9
|
/** Baseline insert input; tests override per case. */
|
|
10
10
|
const input = {
|
|
@@ -95,6 +95,23 @@ describe('upsert', () => {
|
|
|
95
95
|
await db.close()
|
|
96
96
|
})
|
|
97
97
|
|
|
98
|
+
test('behavior: rejects a sign payload already attributed to another org', async () => {
|
|
99
|
+
const db = create()
|
|
100
|
+
await db.migrate()
|
|
101
|
+
const first = await SponsoredTransactions.upsert(db, input)
|
|
102
|
+
|
|
103
|
+
await expect(
|
|
104
|
+
SponsoredTransactions.upsert(db, {
|
|
105
|
+
...input,
|
|
106
|
+
orgId: 'org_2',
|
|
107
|
+
transactionHash: `0x${'dd'.repeat(32)}`,
|
|
108
|
+
}),
|
|
109
|
+
).rejects.toThrow()
|
|
110
|
+
|
|
111
|
+
expect(await SponsoredTransactions.get(db, first.id)).toStrictEqual(first)
|
|
112
|
+
await db.close()
|
|
113
|
+
})
|
|
114
|
+
|
|
98
115
|
test('behavior: a raw submission reopens a failed fill intent', async () => {
|
|
99
116
|
const db = create()
|
|
100
117
|
await db.migrate()
|
|
@@ -172,6 +189,47 @@ describe('assignTransactionHash', () => {
|
|
|
172
189
|
)
|
|
173
190
|
await db.close()
|
|
174
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
|
+
})
|
|
175
233
|
})
|
|
176
234
|
|
|
177
235
|
describe('finalize', () => {
|
|
@@ -47,20 +47,27 @@ export async function upsert(db: Db.Db, input: upsert.Input): Promise<Record> {
|
|
|
47
47
|
.insertInto('sponsored_transactions')
|
|
48
48
|
.values(record)
|
|
49
49
|
.onConflict((oc) =>
|
|
50
|
-
oc
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
50
|
+
oc
|
|
51
|
+
.column('signPayload')
|
|
52
|
+
.doUpdateSet((eb) => ({
|
|
53
|
+
// Same sign payload = same envelope = same cap; only fills older nulls.
|
|
54
|
+
currency: eb.fn.coalesce(eb.ref('sponsored_transactions.currency'), eb.ref('excluded.currency')), // prettier-ignore
|
|
55
|
+
feeMax: eb.fn.coalesce(
|
|
56
|
+
eb.ref('sponsored_transactions.feeMax'),
|
|
57
|
+
eb.ref('excluded.feeMax'),
|
|
58
|
+
),
|
|
59
|
+
// A raw submission fills the intent's hash; a duplicate intent never
|
|
60
|
+
// clears an already-known hash.
|
|
61
|
+
transactionHash: eb.fn.coalesce(
|
|
62
|
+
eb.ref('excluded.transactionHash'),
|
|
63
|
+
eb.ref('sponsored_transactions.transactionHash'),
|
|
64
|
+
),
|
|
65
|
+
createdAt: sql`CASE WHEN ${eb.ref('sponsored_transactions.status')} = 'failed' THEN ${eb.ref('excluded.createdAt')} ELSE ${eb.ref('sponsored_transactions.createdAt')} END`,
|
|
66
|
+
finalizedAt: sql`CASE WHEN ${eb.ref('sponsored_transactions.status')} = 'failed' THEN NULL ELSE ${eb.ref('sponsored_transactions.finalizedAt')} END`,
|
|
67
|
+
status: sql`CASE WHEN ${eb.ref('sponsored_transactions.status')} = 'failed' THEN 'pending' ELSE ${eb.ref('sponsored_transactions.status')} END`,
|
|
68
|
+
}))
|
|
69
|
+
// A shared envelope cannot transfer billing attribution across tenants.
|
|
70
|
+
.where('sponsored_transactions.orgId', '=', input.orgId),
|
|
64
71
|
)
|
|
65
72
|
.returningAll()
|
|
66
73
|
.executeTakeFirstOrThrow()
|
|
@@ -175,19 +182,58 @@ export declare namespace listIntents {
|
|
|
175
182
|
* @param db - The database.
|
|
176
183
|
* @param id - The sponsored-transaction id (`stx_…`).
|
|
177
184
|
* @param transactionHash - The matched on-chain transaction hash.
|
|
185
|
+
* @param limit - The active period spend limit, when enforced.
|
|
178
186
|
*/
|
|
179
187
|
export async function assignTransactionHash(
|
|
180
188
|
db: Db.Db,
|
|
181
189
|
id: string,
|
|
182
190
|
transactionHash: string,
|
|
191
|
+
limit?: reserve.Limit,
|
|
183
192
|
): Promise<void> {
|
|
184
|
-
|
|
185
|
-
.
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
+
}
|
|
191
237
|
}
|
|
192
238
|
|
|
193
239
|
/**
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { nanoid } from 'nanoid'
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import * as Runtime from '../../../test/runtime.js'
|
|
4
4
|
import * as Db from '../Db.js'
|
|
5
5
|
import * as StripeCustomers from './stripeCustomers.js'
|
|
6
6
|
|
|
7
|
-
const create = () => Db.postgres({ connectionString: postgresUrl, schema: `t_${nanoid()}` })
|
|
7
|
+
const create = () => Db.postgres({ connectionString: Runtime.postgresUrl, schema: `t_${nanoid()}` })
|
|
8
8
|
|
|
9
9
|
/** Redacts nondeterministic row fields (timestamps). */
|
|
10
10
|
function redact(record: StripeCustomers.Record) {
|
|
@@ -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`)
|