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.
Files changed (197) hide show
  1. package/dist/App.d.ts +4854 -4829
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js +20 -2
  4. package/dist/App.js.map +1 -1
  5. package/dist/Assets.js +3 -3
  6. package/dist/Assets.js.map +1 -1
  7. package/dist/Scope.d.ts.map +1 -1
  8. package/dist/Scope.js +1 -2
  9. package/dist/Scope.js.map +1 -1
  10. package/dist/Webhooks.d.ts +1 -1
  11. package/dist/Webhooks.d.ts.map +1 -1
  12. package/dist/Webhooks.js +5 -10
  13. package/dist/Webhooks.js.map +1 -1
  14. package/dist/admin/App.d.ts +6 -34
  15. package/dist/admin/App.d.ts.map +1 -1
  16. package/dist/admin/apps/api-keys.d.ts +3 -3
  17. package/dist/admin/apps/api-keys.d.ts.map +1 -1
  18. package/dist/admin/apps/api-keys.js +4 -1
  19. package/dist/admin/apps/api-keys.js.map +1 -1
  20. package/dist/admin/apps/scopes.d.ts +6 -34
  21. package/dist/admin/apps/scopes.d.ts.map +1 -1
  22. package/dist/admin/apps/scopes.js +2 -2
  23. package/dist/admin/apps/scopes.js.map +1 -1
  24. package/dist/admin/apps/verified-tokens.d.ts +2 -2
  25. package/dist/admin/ui.generated.d.ts +1 -1
  26. package/dist/admin/ui.generated.js +33 -33
  27. package/dist/admin/ui.generated.js.map +1 -1
  28. package/dist/apps/data/App.d.ts +1398 -1405
  29. package/dist/apps/data/App.d.ts.map +1 -1
  30. package/dist/apps/data/App.js +25 -38
  31. package/dist/apps/data/App.js.map +1 -1
  32. package/dist/apps/data/FundingQuoteProviders.d.ts +108 -3
  33. package/dist/apps/data/FundingQuoteProviders.d.ts.map +1 -1
  34. package/dist/apps/data/FundingQuoteProviders.js +409 -2
  35. package/dist/apps/data/FundingQuoteProviders.js.map +1 -1
  36. package/dist/apps/data/routes/activities.d.ts +245 -245
  37. package/dist/apps/data/routes/blocks.d.ts +123 -123
  38. package/dist/apps/data/routes/funding.d.ts +236 -146
  39. package/dist/apps/data/routes/funding.d.ts.map +1 -1
  40. package/dist/apps/data/routes/funding.js +23 -12
  41. package/dist/apps/data/routes/funding.js.map +1 -1
  42. package/dist/apps/data/routes/indexer.d.ts.map +1 -1
  43. package/dist/apps/data/routes/indexer.js +9 -2
  44. package/dist/apps/data/routes/indexer.js.map +1 -1
  45. package/dist/apps/data/routes/rpc.d.ts +31 -31
  46. package/dist/apps/data/routes/tokenlist.d.ts +36 -36
  47. package/dist/apps/data/routes/transactions.d.ts +281 -281
  48. package/dist/apps/data/routes/transfers.d.ts +66 -73
  49. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  50. package/dist/apps/data/routes/transfers.js +21 -27
  51. package/dist/apps/data/routes/transfers.js.map +1 -1
  52. package/dist/apps/data/routes/verified-tokens.d.ts +86 -86
  53. package/dist/apps/data/routes/webhooks.d.ts +35 -35
  54. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  55. package/dist/apps/data/routes/webhooks.js +176 -42
  56. package/dist/apps/data/routes/webhooks.js.map +1 -1
  57. package/dist/apps/management/App.d.ts +520 -504
  58. package/dist/apps/management/App.d.ts.map +1 -1
  59. package/dist/apps/management/Billing.d.ts.map +1 -1
  60. package/dist/apps/management/Billing.js +5 -4
  61. package/dist/apps/management/Billing.js.map +1 -1
  62. package/dist/apps/management/routes/api-keys.d.ts +120 -120
  63. package/dist/apps/management/routes/billing.d.ts +211 -211
  64. package/dist/apps/management/routes/billing.d.ts.map +1 -1
  65. package/dist/apps/management/routes/billing.js +1 -1
  66. package/dist/apps/management/routes/billing.js.map +1 -1
  67. package/dist/apps/management/routes/invitations.d.ts +72 -72
  68. package/dist/apps/management/routes/invite-links.d.ts +159 -159
  69. package/dist/apps/management/routes/me.d.ts +17 -17
  70. package/dist/apps/management/routes/members.d.ts +80 -80
  71. package/dist/apps/management/routes/orgs.d.ts +59 -43
  72. package/dist/apps/management/routes/orgs.d.ts.map +1 -1
  73. package/dist/apps/management/routes/orgs.js +18 -1
  74. package/dist/apps/management/routes/orgs.js.map +1 -1
  75. package/dist/apps/management/routes/projects.d.ts +109 -109
  76. package/dist/apps/management/routes/scopes.d.ts +11 -11
  77. package/dist/apps/management/routes/usage.d.ts +79 -79
  78. package/dist/apps/mcp.js +2 -2
  79. package/dist/apps/mcp.js.map +1 -1
  80. package/dist/apps/relay/App.d.ts +2 -2
  81. package/dist/apps/relay/App.js +2 -2
  82. package/dist/apps/relay/Sponsorships.d.ts +3 -0
  83. package/dist/apps/relay/Sponsorships.d.ts.map +1 -1
  84. package/dist/apps/relay/Sponsorships.js +15 -1
  85. package/dist/apps/relay/Sponsorships.js.map +1 -1
  86. package/dist/db/Db.d.ts.map +1 -1
  87. package/dist/db/Db.js +4 -0
  88. package/dist/db/Db.js.map +1 -1
  89. package/dist/db/Schema.js +1 -1
  90. package/dist/db/Schema.js.map +1 -1
  91. package/dist/db/tables/organizations.d.ts +6 -0
  92. package/dist/db/tables/organizations.d.ts.map +1 -1
  93. package/dist/db/tables/organizations.js +26 -0
  94. package/dist/db/tables/organizations.js.map +1 -1
  95. package/dist/db/tables/requestUsage.d.ts +2 -11
  96. package/dist/db/tables/requestUsage.d.ts.map +1 -1
  97. package/dist/db/tables/requestUsage.js +1 -20
  98. package/dist/db/tables/requestUsage.js.map +1 -1
  99. package/dist/db/tables/sponsoredTransactions.d.ts +2 -1
  100. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  101. package/dist/db/tables/sponsoredTransactions.js +52 -10
  102. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  103. package/dist/internal/Auth.d.ts +4 -0
  104. package/dist/internal/Auth.d.ts.map +1 -1
  105. package/dist/internal/Auth.js +15 -8
  106. package/dist/internal/Auth.js.map +1 -1
  107. package/dist/internal/Cache.d.ts.map +1 -1
  108. package/dist/internal/Cache.js +9 -1
  109. package/dist/internal/Cache.js.map +1 -1
  110. package/dist/internal/EdgeCache.d.ts.map +1 -1
  111. package/dist/internal/EdgeCache.js +12 -3
  112. package/dist/internal/EdgeCache.js.map +1 -1
  113. package/dist/internal/FundingQuotes.d.ts +176 -56
  114. package/dist/internal/FundingQuotes.d.ts.map +1 -1
  115. package/dist/internal/FundingQuotes.js +100 -5
  116. package/dist/internal/FundingQuotes.js.map +1 -1
  117. package/dist/internal/Log.d.ts +18 -12
  118. package/dist/internal/Log.d.ts.map +1 -1
  119. package/dist/internal/Log.js +2 -0
  120. package/dist/internal/Log.js.map +1 -1
  121. package/dist/internal/RateLimit.d.ts +2 -0
  122. package/dist/internal/RateLimit.d.ts.map +1 -1
  123. package/dist/internal/RateLimit.js +4 -4
  124. package/dist/internal/RateLimit.js.map +1 -1
  125. package/dist/internal/Tidx.d.ts +9 -3
  126. package/dist/internal/Tidx.d.ts.map +1 -1
  127. package/dist/internal/Tidx.js +13 -7
  128. package/dist/internal/Tidx.js.map +1 -1
  129. package/dist/internal/Viem.d.ts +14 -1
  130. package/dist/internal/Viem.d.ts.map +1 -1
  131. package/dist/internal/Viem.js +19 -1
  132. package/dist/internal/Viem.js.map +1 -1
  133. package/package.json +7 -2
  134. package/src/ApiKeys.test.ts +2 -0
  135. package/src/App.test.ts +15 -4
  136. package/src/App.ts +31 -5
  137. package/src/Assets.ts +3 -3
  138. package/src/Client.test-d.ts +30 -3
  139. package/src/Scope.ts +4 -2
  140. package/src/Webhooks.ts +5 -10
  141. package/src/admin/apps/api-keys.test.ts +27 -0
  142. package/src/admin/apps/api-keys.ts +8 -1
  143. package/src/admin/apps/scopes.test.ts +7 -2
  144. package/src/admin/apps/scopes.ts +2 -2
  145. package/src/admin/ui/src/components/Dialog.tsx +8 -2
  146. package/src/admin/ui/src/lib/scopes.test.ts +19 -0
  147. package/src/admin/ui/src/lib/scopes.ts +12 -0
  148. package/src/admin/ui/src/routes/api-keys.tsx +128 -27
  149. package/src/admin/ui.generated.ts +33 -33
  150. package/src/apps/composition.test.ts +77 -0
  151. package/src/apps/data/App.ts +25 -45
  152. package/src/apps/data/FundingQuoteProviders.test.ts +615 -5
  153. package/src/apps/data/FundingQuoteProviders.ts +570 -4
  154. package/src/apps/data/routes/funding.test.ts +181 -7
  155. package/src/apps/data/routes/funding.ts +27 -12
  156. package/src/apps/data/routes/indexer.test.ts +82 -20
  157. package/src/apps/data/routes/indexer.ts +8 -2
  158. package/src/apps/data/routes/tokens.test.ts +54 -0
  159. package/src/apps/data/routes/transfers.test.ts +73 -1
  160. package/src/apps/data/routes/transfers.ts +24 -30
  161. package/src/apps/data/routes/webhooks.test.ts +203 -0
  162. package/src/apps/data/routes/webhooks.ts +191 -54
  163. package/src/apps/management/Billing.test.ts +52 -3
  164. package/src/apps/management/Billing.ts +5 -4
  165. package/src/apps/management/routes/billing.ts +6 -1
  166. package/src/apps/management/routes/orgs.ts +19 -1
  167. package/src/apps/mcp.test.ts +18 -0
  168. package/src/apps/mcp.ts +2 -2
  169. package/src/apps/relay/App.test.ts +6 -5
  170. package/src/apps/relay/App.ts +2 -2
  171. package/src/apps/relay/Sponsorships.test.ts +2 -2
  172. package/src/apps/relay/Sponsorships.ts +24 -1
  173. package/src/db/Db.test.ts +5 -5
  174. package/src/db/Db.ts +4 -0
  175. package/src/db/Schema.ts +1 -1
  176. package/src/db/tables/billingSettings.test.ts +2 -2
  177. package/src/db/tables/organizations.test.ts +32 -2
  178. package/src/db/tables/organizations.ts +28 -0
  179. package/src/db/tables/requestUsage.ts +3 -23
  180. package/src/db/tables/sponsoredTransactions.test.ts +60 -2
  181. package/src/db/tables/sponsoredTransactions.ts +67 -21
  182. package/src/db/tables/stripeCustomers.test.ts +2 -2
  183. package/src/internal/Auth.test.ts +63 -10
  184. package/src/internal/Auth.ts +21 -9
  185. package/src/internal/Cache.test.ts +19 -0
  186. package/src/internal/Cache.ts +13 -1
  187. package/src/internal/EdgeCache.test.ts +40 -0
  188. package/src/internal/EdgeCache.ts +11 -3
  189. package/src/internal/FundingQuotes.test.ts +130 -3
  190. package/src/internal/FundingQuotes.ts +113 -6
  191. package/src/internal/Log.ts +21 -14
  192. package/src/internal/RateLimit.test.ts +12 -0
  193. package/src/internal/RateLimit.ts +4 -4
  194. package/src/internal/Tidx.test.ts +48 -0
  195. package/src/internal/Tidx.ts +21 -7
  196. package/src/internal/Viem.test.ts +46 -0
  197. 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(db, intent.id, hash)
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 { postgresUrl } from '../../test/containers.js'
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('Delta sequence within the bucket; `0` is the initial seal, `1..n` later deltas.')), // prettier-ignore
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 { postgresUrl } from '../../../test/containers.js'
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 { postgresUrl } from '../../../test/containers.js'
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: number
35
+ sequence: Count
36
36
  state: State
37
37
  stripeCustomerId: string
38
38
  }
@@ -91,26 +91,6 @@ export async function listPending(db: Db.Db, environment: Environment): Promise<
91
91
  return rows.map(toMeterEvent)
92
92
  }
93
93
 
94
- /**
95
- * Counts the meter events already reported for a bucket — the next delta's
96
- * `sequence`, so a new seal never collides with a settled one.
97
- *
98
- * @param db - The database.
99
- * @param key - The bucket key.
100
- * @returns The count of reported events for the bucket.
101
- */
102
- export async function countReported(db: Db.Db, key: BucketKey): Promise<number> {
103
- const row = await db.kysely
104
- .selectFrom('request_usage_meter_events')
105
- .select((eb) => eb.fn.countAll<string>().as('count'))
106
- .where('orgId', '=', key.orgId)
107
- .where('environment', '=', key.environment)
108
- .where('bucketStart', '=', key.bucketStart)
109
- .where('state', '=', 'reported')
110
- .executeTakeFirstOrThrow()
111
- return Number(row.count)
112
- }
113
-
114
94
  /**
115
95
  * Inserts a frozen `pending` meter event, claiming a delta. Idempotent on the
116
96
  * deterministic `identifier`: a re-seal after a crash resolves to the existing
@@ -157,7 +137,7 @@ export declare namespace insertPending {
157
137
  identifier: string
158
138
  /** Organization id (`org_…`). */
159
139
  orgId: string
160
- /** Delta sequence within the bucket. */
140
+ /** Reported-count watermark observed when this delta was claimed. */
161
141
  sequence: number
162
142
  /** Stripe customer id the event bills against. */
163
143
  stripeCustomerId: string
@@ -227,5 +207,5 @@ function toBucket(row: Selectable<BucketTable>): Bucket {
227
207
 
228
208
  /** Maps a meter-event row to its domain record. */
229
209
  function toMeterEvent(row: Selectable<MeterEventTable>): MeterEvent {
230
- return { ...row, deltaCount: Number(row.deltaCount) }
210
+ return { ...row, deltaCount: Number(row.deltaCount), sequence: Number(row.sequence) }
231
211
  }
@@ -1,10 +1,10 @@
1
1
  import { nanoid } from 'nanoid'
2
2
 
3
- import { postgresUrl } from '../../../test/containers.js'
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.column('signPayload').doUpdateSet((eb) => ({
51
- // Same sign payload = same envelope = same cap; only fills older nulls.
52
- currency: eb.fn.coalesce(eb.ref('sponsored_transactions.currency'), eb.ref('excluded.currency')), // prettier-ignore
53
- feeMax: eb.fn.coalesce(eb.ref('sponsored_transactions.feeMax'), eb.ref('excluded.feeMax')),
54
- // A raw submission fills the intent's hash; a duplicate intent never
55
- // clears an already-known hash.
56
- transactionHash: eb.fn.coalesce(
57
- eb.ref('excluded.transactionHash'),
58
- eb.ref('sponsored_transactions.transactionHash'),
59
- ),
60
- createdAt: sql`CASE WHEN ${eb.ref('sponsored_transactions.status')} = 'failed' THEN ${eb.ref('excluded.createdAt')} ELSE ${eb.ref('sponsored_transactions.createdAt')} END`,
61
- finalizedAt: sql`CASE WHEN ${eb.ref('sponsored_transactions.status')} = 'failed' THEN NULL ELSE ${eb.ref('sponsored_transactions.finalizedAt')} END`,
62
- status: sql`CASE WHEN ${eb.ref('sponsored_transactions.status')} = 'failed' THEN 'pending' ELSE ${eb.ref('sponsored_transactions.status')} END`,
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
- await db.kysely
185
- .updateTable('sponsored_transactions')
186
- .set({ finalizedAt: null, status: 'pending', transactionHash })
187
- .where('id', '=', id)
188
- .where('status', 'in', ['failed', 'pending'])
189
- .where('transactionHash', 'is', null)
190
- .execute()
193
+ try {
194
+ await db.transaction(async (tx) => {
195
+ const record = await get(tx, id)
196
+ if (
197
+ !record ||
198
+ record.transactionHash !== null ||
199
+ !['failed', 'pending'].includes(record.status)
200
+ )
201
+ return
202
+ if (record.status === 'failed' && record.billable && limit)
203
+ await sql`select pg_advisory_xact_lock(${billingLockClass}, hashtext(${record.orgId}))`.execute(tx.kysely) // prettier-ignore
204
+ await tx.kysely
205
+ .updateTable('sponsored_transactions')
206
+ .set({
207
+ ...(record.status === 'failed' ? { createdAt: new Date().toISOString() } : {}),
208
+ finalizedAt: null,
209
+ status: 'pending',
210
+ transactionHash,
211
+ })
212
+ .where('id', '=', id)
213
+ .where('status', '=', record.status)
214
+ .where('transactionHash', 'is', null)
215
+ .execute()
216
+ if (record.status === 'failed' && record.billable && limit) {
217
+ const committed = await spend(tx, {
218
+ chainIds: limit.chainIds,
219
+ orgId: record.orgId,
220
+ since: limit.since,
221
+ })
222
+ if (committed > limit.max) throw new PeriodSpendLimitError()
223
+ }
224
+ })
225
+ } catch (error) {
226
+ // Keep the on-chain match eligible until budget becomes available.
227
+ if (error instanceof PeriodSpendLimitError)
228
+ await db.kysely
229
+ .updateTable('sponsored_transactions')
230
+ .set({ finalizedAt: new Date().toISOString() })
231
+ .where('id', '=', id)
232
+ .where('status', '=', 'failed')
233
+ .where('transactionHash', 'is', null)
234
+ .execute()
235
+ throw error
236
+ }
191
237
  }
192
238
 
193
239
  /**
@@ -1,10 +1,10 @@
1
1
  import { nanoid } from 'nanoid'
2
2
 
3
- import { postgresUrl } from '../../../test/containers.js'
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 of 100 per minute', async () => {
746
+ test('uses a default paid-request limit with a normalized IPv6 identity', async () => {
747
747
  const rateLimit = underQuota()
748
748
  const consume = vi.spyOn(rateLimit, 'consume')
749
749
  const app = createApp({
@@ -757,7 +757,7 @@ describe('require', () => {
757
757
  const response = await app.request('/protected', {
758
758
  headers: {
759
759
  authorization: 'Payment paid',
760
- 'x-forwarded-for': '203.0.113.10',
760
+ 'cf-connecting-ip': '2001:db8:abcd:12::1',
761
761
  },
762
762
  })
763
763
 
@@ -765,7 +765,60 @@ describe('require', () => {
765
765
  expect(response.headers.get('RateLimit-Limit')).toBe('100')
766
766
  expect(response.headers.get('RateLimit-Scope')).toBe('mpp')
767
767
  expect(consume).toHaveBeenCalledWith({
768
- key: 'mpp:public:203.0.113.10',
768
+ key: 'mpp:public:2001:db8:abcd:12',
769
+ limit: { perMinute: 100 },
770
+ })
771
+ })
772
+
773
+ test('ignores untrusted forwarding headers for public identity', async () => {
774
+ const rateLimit = underQuota()
775
+ const consume = vi.spyOn(rateLimit, 'consume')
776
+ const app = createApp({
777
+ auth: {
778
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
779
+ mpp: createMppHandler(),
780
+ rateLimit,
781
+ },
782
+ policy: { mpp: { session: { amount: '1' } } },
783
+ })
784
+ const response = await app.request('/protected', {
785
+ headers: {
786
+ authorization: 'Payment paid',
787
+ 'x-forwarded-for': '203.0.113.10',
788
+ 'x-real-ip': '203.0.113.11',
789
+ 'x-test-client-ip': '203.0.113.12',
790
+ },
791
+ })
792
+
793
+ expect(response.status).toBe(200)
794
+ expect(consume).toHaveBeenCalledWith({
795
+ key: 'mpp:public:anonymous',
796
+ limit: { perMinute: 100 },
797
+ })
798
+ })
799
+
800
+ test('uses a configured trusted client-IP resolver outside Cloudflare', async () => {
801
+ const rateLimit = underQuota()
802
+ const consume = vi.spyOn(rateLimit, 'consume')
803
+ const app = createApp({
804
+ auth: {
805
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
806
+ mpp: createMppHandler(),
807
+ publicClientIp: (request) => request.headers.get('x-real-ip') ?? undefined,
808
+ rateLimit,
809
+ },
810
+ policy: { mpp: { session: { amount: '1' } } },
811
+ })
812
+ const response = await app.request('/protected', {
813
+ headers: {
814
+ authorization: 'Payment paid',
815
+ 'x-real-ip': '2001:db8:abcd:12::99',
816
+ },
817
+ })
818
+
819
+ expect(response.status).toBe(200)
820
+ expect(consume).toHaveBeenCalledWith({
821
+ key: 'mpp:public:2001:db8:abcd:12',
769
822
  limit: { perMinute: 100 },
770
823
  })
771
824
  })
@@ -788,7 +841,7 @@ describe('require', () => {
788
841
  headers: {
789
842
  authorization: 'Payment paid',
790
843
  'tempo-api-key': token,
791
- 'x-forwarded-for': '203.0.113.10',
844
+ 'cf-connecting-ip': '203.0.113.10',
792
845
  },
793
846
  })
794
847
 
@@ -1228,7 +1281,7 @@ describe('require', () => {
1228
1281
  policy: { public: { rateLimit: { perMinute: 60 } } },
1229
1282
  })
1230
1283
  const response = await app.request('/protected', {
1231
- headers: { 'x-forwarded-for': '203.0.113.10' },
1284
+ headers: { 'cf-connecting-ip': '203.0.113.10' },
1232
1285
  })
1233
1286
  const body = await response.json()
1234
1287
 
@@ -1280,7 +1333,7 @@ describe('require', () => {
1280
1333
  policy: { public: { rateLimit: { perMinute: 1 } } },
1281
1334
  })
1282
1335
  const response = await app.request('/protected', {
1283
- headers: { 'x-forwarded-for': '203.0.113.10' },
1336
+ headers: { 'cf-connecting-ip': '203.0.113.10' },
1284
1337
  })
1285
1338
  const body = await response.json()
1286
1339
 
@@ -1338,7 +1391,7 @@ describe('require', () => {
1338
1391
  policy: { public: { rateLimit: { perMinute: 1 } } },
1339
1392
  })
1340
1393
  const pending = app.request('/protected', {
1341
- headers: { 'x-forwarded-for': '203.0.113.10' },
1394
+ headers: { 'cf-connecting-ip': '203.0.113.10' },
1342
1395
  })
1343
1396
  // Advance past the 300ms consume timeout so it fails open.
1344
1397
  await vi.advanceTimersByTimeAsync(300)
@@ -1382,7 +1435,7 @@ describe('require', () => {
1382
1435
  policy: { public: { rateLimit: { perMinute: 1 } } },
1383
1436
  })
1384
1437
  const response = await app.request('/protected', {
1385
- headers: { 'x-forwarded-for': '203.0.113.10' },
1438
+ headers: { 'cf-connecting-ip': '203.0.113.10' },
1386
1439
  })
1387
1440
  const body = await response.json()
1388
1441
 
@@ -1419,7 +1472,7 @@ describe('require', () => {
1419
1472
  policy: { public: { rateLimit: { perMinute: 1 } } },
1420
1473
  })
1421
1474
  const response = await app.request('/protected', {
1422
- headers: { 'x-forwarded-for': '203.0.113.10' },
1475
+ headers: { 'cf-connecting-ip': '203.0.113.10' },
1423
1476
  })
1424
1477
 
1425
1478
  expect(response.status).toMatchInlineSnapshot(`200`)
@@ -1448,7 +1501,7 @@ describe('require', () => {
1448
1501
  policy: { public: { rateLimit: { perMinute: 1 } } },
1449
1502
  })
1450
1503
  const response = await app.request('/protected', {
1451
- headers: { 'x-forwarded-for': '203.0.113.10' },
1504
+ headers: { 'cf-connecting-ip': '203.0.113.10' },
1452
1505
  })
1453
1506
 
1454
1507
  expect(response.status).toMatchInlineSnapshot(`200`)