Files
hosted-gateway/prisma/schema.prisma
T
Claude 5b2888aade fix: reservation leak, spoofable delivery envelopes, and Decimal wei rendering
Seven bugs found by auditing the billing and PSS paths.

1. Reservations were never released on ack/delivery timeout. Both failure
   paths in gateway/context.ts marked the GatewayJob terminal *before* calling
   releaseReservation, and releaseReservation bailed out on exactly those
   statuses — so every failed job permanently inflated the tenant's
   reservedXbzzWei and shrank their available balance. Replace the status check
   with GatewayJob.reservationReleasedAt as an explicit compare-and-set marker,
   which makes the release exactly-once regardless of ordering, and give
   settleXbzz the same guard so a job that was released locally and claimed
   on-chain anyway can't decrement the reservation twice. The migration returns
   the xBZZ already stuck on existing accounts.

2. job_ack / job_deliver were only checked for a valid signature, never for
   *whose* signature. Anyone who learned a routing id could sign their own
   job_deliver, pointing at a chunk encrypted to the gateway's (published) PSS
   key, and hand the tenant an arbitrary completion. Drop any envelope whose
   `from` isn't the provider the job was posted to.

3. BigInt(decimal.toString()) throws on any wei value >= 1e21 (1000 xBZZ),
   because decimal.js switches to exponential notation there. That took out
   GET /accounts/me and, worse, wedged the claim watcher's settle loop. Route
   every Decimal→string conversion through toFixed().

4. A cancelJob failure at the ACK deadline left the job with no timer at all,
   so the chat request hung forever holding a semaphore slot and a reservation.
   Fall through to the delivery deadline instead.

5. `?limit=abc` reached Prisma as `take: NaN` (500). Clamp and fall back.

6. A fractional max_tokens made BigInt() throw; a negative one budgeted a
   negative reservation. Validate at the schema and defend in the estimator.

7. model.<entity>.delete() announced the deletion on the queue before
   performing it, so a failed delete still told consumers the row was gone.

Verified against a real Postgres: the reservation lifecycle (release-after-
terminal, settle-after-release, concurrent release/settle, idempotent settle)
and the migration's repair of leaked reservations on pre-fix data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mfkh741kNCZeBdk4xyM9qR
2026-08-07 07:28:09 +00:00

231 lines
7.0 KiB
Plaintext

// Hosted T4T Gateway — Prisma schema.
//
// One operator wallet handles all on-chain transactions. Tenants are off-chain
// ledger entries denominated in xBZZ wei (Decimal(78, 0) holds any uint256).
// USD is never in the billing path — only in `Topup` rows for Phase 2 Stripe.
generator client {
provider = "prisma-client-js"
}
// Connection URL + seed config live in prisma.config.ts (Prisma 7).
datasource db {
provider = "postgresql"
}
// -----------------------------------------------------------------------------
// Enums
// -----------------------------------------------------------------------------
enum UserRole {
USER
ADMIN
}
enum AuthProvider {
google
email
}
enum AccountStatus {
active
disabled
}
enum LedgerKind {
welcomeGrant
settle
refund
topup
adjustment
}
enum GatewayJobStatus {
posted
acked
delivered
claimed
cancelled
timed_out
failed
}
enum TopupStatus {
requires_payment
processing
succeeded
credited
failed
refunded
}
// -----------------------------------------------------------------------------
// User + auth
// -----------------------------------------------------------------------------
model User {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
email String @unique
emailVerified Boolean @default(false)
// Single-use email verification token (NULL once consumed). For Phase 1 we
// surface this in the signup response in dev/test mode; Phase 1.5 wires up
// a real outbound mailer and stops echoing the token back.
verificationToken String? @unique
authProvider AuthProvider
googleSub String? @unique
passwordHash String?
role UserRole @default(USER)
// Phase 2 — Stripe handles billing address + sanctions screening
stripeCustomerId String? @unique
country String? // ISO 3166-1 alpha-2, mirrored from Stripe
account Account?
apiKeys ApiKey[]
gatewayJobs GatewayJob[]
topups Topup[]
@@index([authProvider, googleSub])
}
model ApiKey {
id String @id @default(uuid())
createdAt DateTime @default(now())
key String @unique @default(uuid())
name String @default("")
lastUsedAt DateTime?
scopeMaxXbzzPerDay Decimal? @db.Decimal(78, 0)
modelAllowlist String[] @default([])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
@@index([userId])
}
// -----------------------------------------------------------------------------
// Account + ledger
// -----------------------------------------------------------------------------
model Account {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
status AccountStatus @default(active)
balanceXbzzWei Decimal @default(0) @db.Decimal(78, 0)
reservedXbzzWei Decimal @default(0) @db.Decimal(78, 0)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @unique
ledgerEntries LedgerEntry[]
}
model LedgerEntry {
id String @id @default(uuid())
createdAt DateTime @default(now())
kind LedgerKind
deltaXbzzWei Decimal @db.Decimal(78, 0)
idempotencyKey String @unique
note String?
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
accountId String
gatewayJob GatewayJob? @relation(fields: [gatewayJobId], references: [id], onDelete: SetNull)
gatewayJobId String?
topup Topup? @relation(fields: [topupId], references: [id], onDelete: SetNull)
topupId String?
@@index([accountId, createdAt(sort: Desc)])
@@index([kind])
}
// -----------------------------------------------------------------------------
// Gateway jobs (per-chat-completion, settled on JobClaimed)
// -----------------------------------------------------------------------------
model GatewayJob {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Routing
routingId String @unique // local routing id (keccak of request hash etc.)
onChainJobId String? @unique // bytes32 from JobEscrow.JobPosted
requestHash String? // Swarm chunk ref of encrypted request
responseHash String? // Swarm chunk ref of encrypted response
// Counterparties / model
provider String // 0x address selected by the gateway
modelId String
// Billing (xBZZ-denominated; markup baked in)
estimatedMaxXbzzWei Decimal @db.Decimal(78, 0)
actualXbzzWei Decimal? @db.Decimal(78, 0)
// Lifecycle
status GatewayJobStatus @default(posted)
postedAt DateTime @default(now())
ackedAt DateTime?
deliveredAt DateTime?
claimedAt DateTime?
ackDeadline DateTime?
deliveryDeadline DateTime?
// Set the first time this job's off-chain reservation is released — by
// `settleXbzz` on JobClaimed, or by `releaseReservation` on cancel / timeout
// / dispatch failure. It is the compare-and-set marker that makes the
// release exactly-once: `status` cannot serve that role because a job can be
// marked terminal and released by different code paths in either order.
reservationReleasedAt DateTime?
// Token accounting (from JobClaimed actualPayment)
promptTokens Int?
completionTokens Int?
// Optional payload retention (off by default; T4T_PERSIST_PAYLOADS-style)
promptRedacted String?
responseRedacted String?
errorMessage String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
ledgerEntries LedgerEntry[]
@@index([userId, createdAt(sort: Desc)])
@@index([status])
@@index([onChainJobId])
}
// -----------------------------------------------------------------------------
// Stripe top-ups (Phase 2)
// -----------------------------------------------------------------------------
model Topup {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
status TopupStatus @default(requires_payment)
stripePaymentIntent String @unique
amountUsdCents Int
creditedXbzzWei Decimal? @db.Decimal(78, 0)
usdPerXbzzAtCredit Decimal? @db.Decimal(18, 8)
error String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
ledgerEntries LedgerEntry[]
@@index([userId, createdAt(sort: Desc)])
@@index([status])
}