From c54e8884586d881f290d1b481e1ca67749b83288 Mon Sep 17 00:00:00 2001 From: Felix Faerber Date: Wed, 20 May 2026 15:08:45 +0300 Subject: [PATCH] init --- .dockerignore | 6 + .env.example | 45 ++ .gitignore | 6 + Dockerfile | 23 + README.md | 47 ++ bun.lock | 610 ++++++++++++++++++ bunfig.toml | 2 + package.json | 47 ++ prisma.config.ts | 14 + .../20260520091204_init/migration.sql | 201 ++++++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 223 +++++++ prisma/seed/index.ts | 18 + src/accounts/ledger.ts | 145 +++++ src/accounts/processor.ts | 36 ++ src/accounts/routes.ts | 61 ++ src/api.ts | 42 ++ src/apiKeys/routes.ts | 60 ++ src/auth/google.ts | 37 ++ src/auth/jwt.ts | 75 +++ src/auth/routes.ts | 139 ++++ src/chain/balanceMonitor.ts | 56 ++ src/chain/claimWatcher.ts | 108 ++++ src/chain/operatorChain.ts | 47 ++ src/db.ts | 34 + src/env.ts | 85 +++ src/gateway/context.ts | 335 ++++++++++ src/gateway/handleChat.ts | 183 ++++++ src/gateway/routes.ts | 110 ++++ src/logger.ts | 12 + src/pricing/estimate.ts | 49 ++ src/pricing/usdDisplay.ts | 21 + src/queue/connection.ts | 8 + src/queue/lock.ts | 37 ++ src/queue/processor.ts | 92 +++ src/queue/registry.ts | 43 ++ src/queue/semaphore.ts | 47 ++ src/queue/service.ts | 58 ++ src/queue/startup.ts | 27 + src/t4t/VENDORED.md | 64 ++ src/t4t/lib/abi.ts | 238 +++++++ src/t4t/lib/bzz-price.ts | 47 ++ src/t4t/lib/chain.ts | 353 ++++++++++ src/t4t/lib/crypto.ts | 89 +++ src/t4t/lib/envelope.ts | 120 ++++ src/t4t/lib/keys.ts | 113 ++++ src/t4t/lib/logger.ts | 4 + src/t4t/lib/stamps.ts | 292 +++++++++ src/t4t/lib/swarm.ts | 178 +++++ src/t4t/lib/types.ts | 115 ++++ src/t4t/lib/wallet.ts | 54 ++ src/t4t/modes/gateway/models.ts | 130 ++++ src/t4t/modes/gateway/selector.ts | 114 ++++ src/users/processor.ts | 51 ++ src/users/routes.ts | 21 + src/worker.ts | 18 + test/auth.e2e-spec.ts | 6 + test/auth.ts | 204 ++++++ test/fixtures/fundOperator.ts | 122 ++++ test/gateway.e2e-spec.ts | 4 + test/gateway.ts | 96 +++ test/health.e2e-spec.ts | 11 + test/helpers.ts | 144 +++++ test/models.e2e-spec.ts | 4 + test/models.ts | 62 ++ test/setup.ts | 66 ++ tsconfig.json | 21 + 67 files changed, 5933 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 bun.lock create mode 100644 bunfig.toml create mode 100644 package.json create mode 100644 prisma.config.ts create mode 100644 prisma/migrations/20260520091204_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed/index.ts create mode 100644 src/accounts/ledger.ts create mode 100644 src/accounts/processor.ts create mode 100644 src/accounts/routes.ts create mode 100644 src/api.ts create mode 100644 src/apiKeys/routes.ts create mode 100644 src/auth/google.ts create mode 100644 src/auth/jwt.ts create mode 100644 src/auth/routes.ts create mode 100644 src/chain/balanceMonitor.ts create mode 100644 src/chain/claimWatcher.ts create mode 100644 src/chain/operatorChain.ts create mode 100644 src/db.ts create mode 100644 src/env.ts create mode 100644 src/gateway/context.ts create mode 100644 src/gateway/handleChat.ts create mode 100644 src/gateway/routes.ts create mode 100644 src/logger.ts create mode 100644 src/pricing/estimate.ts create mode 100644 src/pricing/usdDisplay.ts create mode 100644 src/queue/connection.ts create mode 100644 src/queue/lock.ts create mode 100644 src/queue/processor.ts create mode 100644 src/queue/registry.ts create mode 100644 src/queue/semaphore.ts create mode 100644 src/queue/service.ts create mode 100644 src/queue/startup.ts create mode 100644 src/t4t/VENDORED.md create mode 100644 src/t4t/lib/abi.ts create mode 100644 src/t4t/lib/bzz-price.ts create mode 100644 src/t4t/lib/chain.ts create mode 100644 src/t4t/lib/crypto.ts create mode 100644 src/t4t/lib/envelope.ts create mode 100644 src/t4t/lib/keys.ts create mode 100644 src/t4t/lib/logger.ts create mode 100644 src/t4t/lib/stamps.ts create mode 100644 src/t4t/lib/swarm.ts create mode 100644 src/t4t/lib/types.ts create mode 100644 src/t4t/lib/wallet.ts create mode 100644 src/t4t/modes/gateway/models.ts create mode 100644 src/t4t/modes/gateway/selector.ts create mode 100644 src/users/processor.ts create mode 100644 src/users/routes.ts create mode 100644 src/worker.ts create mode 100644 test/auth.e2e-spec.ts create mode 100644 test/auth.ts create mode 100644 test/fixtures/fundOperator.ts create mode 100644 test/gateway.e2e-spec.ts create mode 100644 test/gateway.ts create mode 100644 test/health.e2e-spec.ts create mode 100644 test/helpers.ts create mode 100644 test/models.e2e-spec.ts create mode 100644 test/models.ts create mode 100644 test/setup.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c071f14 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.env +.env.local +*.log +.git diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..adcb2dc --- /dev/null +++ b/.env.example @@ -0,0 +1,45 @@ +# --- Required --- + +# Postgres (Prisma) +DATABASE_URL=postgresql://hosted:hosted@localhost:5432/hosted_gateway?schema=public + +# Redis (BullMQ + locks + nonce) +REDIS_HOST=localhost +REDIS_PORT=6379 + +# Auth +JWT_SECRET_KEY=change-me-to-a-long-random-string +GOOGLE_OAUTH_CLIENT_ID= + +# Operator wallet (the ONLY on-chain identity) +OPERATOR_PRIVATE_KEY=0x... +GNOSIS_RPC_URL=https://rpc.gnosischain.com + +# T4T protocol contract addresses (Gnosis mainnet — managed by the t4t repo). +# These are the canonical live deployment; override only when pointing at a +# private fork or testnet (Chiado). +REGISTRY_ADDRESS=0xf81121AAbc2F7261224BaDd0Ed871711e6D1371E +ESCROW_ADDRESS=0x34Db8E014E71928f17E23eC1272B602582222c9c +XBZZ_ADDRESS=0xdBF3Ea6F5beE45c02255B2c26a16F300502F68da + +# Bee node (operator-owned, single postage batch) +BEE_API_URL=http://localhost:1633 +BEE_POSTAGE_BATCH_ID= +BEE_STAMP_MANAGE=true +BEE_STAMP_LABEL=hosted-gateway +BEE_STAMP_TTL_DAYS=30 + +# Pricing +MARKUP_BPS=2000 # 20% operator markup on xBZZ cost +WELCOME_XBZZ_WEI=50000000000000000000 # 50 xBZZ in wei + +# Concurrency +MAX_CONCURRENT_JOBS=200 + +# Server +HOSTED_GATEWAY_PORT=8080 +LOG_LEVEL=info + +# --- Phase 2 (Stripe) — leave blank in v1 --- +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0067c72 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +.env +.env.local +*.log +bun.lock.bak diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..19e457c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM oven/bun:1.2-alpine + +WORKDIR /app + +# Install dependencies +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile 2>/dev/null || bun install + +# Generate Prisma client +COPY prisma ./prisma +RUN bunx prisma generate + +# Copy source (ARG busts cache on code changes) +ARG CACHE_BUST=1 +COPY . . + +ARG COMMIT_SHA +ENV COMMIT_SHA=$COMMIT_SHA + +EXPOSE 8080 + +# Default command; docker-compose overrides for the worker process. +CMD ["bun", "run", "src/api.ts"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9988dbb --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Hosted T4T Gateway + +Multi-tenant frontend for the [Token4Token](https://github.com/token-for-token/t4t) inference network. Users sign up with Google / email, get 50 free virtual xBZZ, and call OpenAI-compatible endpoints without touching a wallet, Bee node, or Gnosis RPC. + +**Custody model:** one operator wallet handles all on-chain transactions and Swarm operations. Tenants are off-chain ledger entries denominated in xBZZ. Providers see one on-chain client address (the operator's), not per-tenant addresses. + +See [docs in t4t/](https://github.com/token-for-token/t4t/blob/main/docs/proposal-centralized-gateway.md) for the wider design context. + +## Stack + +- Bun + Elysia (HTTP) +- viem (Gnosis Chain bindings) — shared with `t4t-container` +- bee-js (Swarm) — shared with `t4t-container` +- Prisma + Postgres (multi-tenant ledger) +- BullMQ + Redis (queues + distributed locks + operator-wallet nonce) + +The gateway engine code (provider selection, chain I/O, PSS routing, ECIES envelope) is vendored from [`t4t/container/src/`](../t4t/container/src) into `src/t4t/`. See [src/t4t/VENDORED.md](src/t4t/VENDORED.md) for the drift policy. + +## Quick start + +```bash +cp .env.example .env +# fill in OPERATOR_PRIVATE_KEY, REGISTRY/ESCROW/XBZZ addresses, JWT_SECRET_KEY + +docker compose up -d postgres redis bee +bun install +bunx prisma migrate deploy +bun run dev:api +# in another shell: +bun run dev:worker +``` + +## Layout + +``` +src/ + api.ts # Elysia HTTP entry + worker.ts # BullMQ workers + claim watcher + db.ts # model. CUD wrappers (write + enqueue) + env.ts # zod-validated process.env + auth/ # JWT, Google OAuth, email signup + users/, accounts/, apiKeys/, pricing/, gateway/, chain/ + queue/ # connection, registry, lock, processor base, service + t4t/ # VENDORED from t4t-container +prisma/schema.prisma +test/ # E2E specs +``` diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..0495e78 --- /dev/null +++ b/bun.lock @@ -0,0 +1,610 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "hosted-gateway", + "dependencies": { + "@elysiajs/bearer": "^1.2.0", + "@elysiajs/cors": "^1.2.0", + "@elysiajs/swagger": "^1.2.0", + "@ethersphere/bee-js": "^11.0.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.0", + "@noble/hashes": "^1.8.0", + "@prisma/adapter-pg": "^7.0.0", + "@prisma/client": "^7.0.0", + "argon2": "^0.41.1", + "bullmq": "^5.34.0", + "elysia": "^1.2.25", + "google-auth-library": "^9.15.0", + "ioredis": "^5.6.0", + "jsonwebtoken": "^9.0.2", + "pino": "^9.5.0", + "pino-pretty": "^11.2.2", + "viem": "^2.21.0", + "zod": "^3.23.8", + }, + "devDependencies": { + "@types/jsonwebtoken": "^9.0.9", + "bun-types": "latest", + "prisma": "^7.0.0", + "typescript": "^5.7", + }, + }, + }, + "packages": { + "@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="], + + "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], + + "@electric-sql/pglite": ["@electric-sql/pglite@0.4.1", "", {}, "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q=="], + + "@electric-sql/pglite-socket": ["@electric-sql/pglite-socket@0.1.1", "", { "peerDependencies": { "@electric-sql/pglite": "0.4.1" }, "bin": { "pglite-server": "dist/scripts/server.js" } }, "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw=="], + + "@electric-sql/pglite-tools": ["@electric-sql/pglite-tools@0.3.1", "", { "peerDependencies": { "@electric-sql/pglite": "0.4.1" } }, "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA=="], + + "@elysiajs/bearer": ["@elysiajs/bearer@1.4.4", "", { "peerDependencies": { "elysia": ">= 1.4.3" } }, "sha512-NCMiTEwLmqfxkbaTHYvZXvmO+CHmHVNlLLNa+0RcJviK88abVeSnn6wswgABGGTRJJTkfw3Ks3fw/7um3bxeRQ=="], + + "@elysiajs/cors": ["@elysiajs/cors@1.4.2", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-FTCcbH35brTLigF1W7BYySRZomgI/dBEMK9BgK9RP9Nez7zmpGh4koL/Yr1BFv8nYz7CfhRvcM8d/c+XnwMaVQ=="], + + "@elysiajs/swagger": ["@elysiajs/swagger@1.3.1", "", { "dependencies": { "@scalar/themes": "^0.9.52", "@scalar/types": "^0.0.12", "openapi-types": "^12.1.3", "pathe": "^1.1.2" }, "peerDependencies": { "elysia": ">= 1.3.0" } }, "sha512-LcbLHa0zE6FJKWPWKsIC/f+62wbDv3aXydqcNPVPyqNcaUgwvCajIi+5kHEU6GO3oXUCpzKaMsb3gsjt8sLzFQ=="], + + "@ethersphere/bee-js": ["@ethersphere/bee-js@11.2.0", "", { "dependencies": { "axios": "^0.30.2", "cafe-utility": "^33.6.1", "debug": "^4.4.1", "isomorphic-ws": "^4.0.1", "semver": "^7.3.5", "ws": "^8.7.0" } }, "sha512-Cn1NCy6/g+LzTXBWxFftT7OWfCj4CXGK5lo3VHVp0v3erytFqoBQkKLZ1TiwE4En5/voN7GCe5kjy3El8l8lRA=="], + + "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], + + "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="], + + "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], + + "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], + + "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@phc/format": ["@phc/format@1.0.0", "", {}, "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ=="], + + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + + "@prisma/adapter-pg": ["@prisma/adapter-pg@7.8.0", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.8.0", "@types/pg": "^8.16.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg=="], + + "@prisma/client": ["@prisma/client@7.8.0", "", { "dependencies": { "@prisma/client-runtime-utils": "7.8.0" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw=="], + + "@prisma/client-runtime-utils": ["@prisma/client-runtime-utils@7.8.0", "", {}, "sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw=="], + + "@prisma/config": ["@prisma/config@7.8.0", "", { "dependencies": { "c12": "3.3.4", "deepmerge-ts": "7.1.5", "effect": "3.20.0", "empathic": "2.0.0" } }, "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw=="], + + "@prisma/debug": ["@prisma/debug@7.8.0", "", {}, "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA=="], + + "@prisma/dev": ["@prisma/dev@0.24.3", "", { "dependencies": { "@electric-sql/pglite": "0.4.1", "@electric-sql/pglite-socket": "0.1.1", "@electric-sql/pglite-tools": "0.3.1", "@hono/node-server": "1.19.11", "@prisma/get-platform": "7.2.0", "@prisma/query-plan-executor": "7.2.0", "@prisma/streams-local": "0.1.2", "foreground-child": "3.3.1", "get-port-please": "3.2.0", "hono": "^4.12.8", "http-status-codes": "2.3.0", "pathe": "2.0.3", "proper-lockfile": "4.1.2", "remeda": "2.33.4", "std-env": "3.10.0", "valibot": "1.2.0", "zeptomatch": "2.1.0" } }, "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg=="], + + "@prisma/driver-adapter-utils": ["@prisma/driver-adapter-utils@7.8.0", "", { "dependencies": { "@prisma/debug": "7.8.0" } }, "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A=="], + + "@prisma/engines": ["@prisma/engines@7.8.0", "", { "dependencies": { "@prisma/debug": "7.8.0", "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", "@prisma/fetch-engine": "7.8.0", "@prisma/get-platform": "7.8.0" } }, "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw=="], + + "@prisma/engines-version": ["@prisma/engines-version@7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", "", {}, "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA=="], + + "@prisma/fetch-engine": ["@prisma/fetch-engine@7.8.0", "", { "dependencies": { "@prisma/debug": "7.8.0", "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", "@prisma/get-platform": "7.8.0" } }, "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ=="], + + "@prisma/get-platform": ["@prisma/get-platform@7.2.0", "", { "dependencies": { "@prisma/debug": "7.2.0" } }, "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA=="], + + "@prisma/query-plan-executor": ["@prisma/query-plan-executor@7.2.0", "", {}, "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ=="], + + "@prisma/streams-local": ["@prisma/streams-local@0.1.2", "", { "dependencies": { "ajv": "^8.12.0", "better-result": "^2.7.0", "env-paths": "^3.0.0", "proper-lockfile": "^4.1.2" } }, "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg=="], + + "@prisma/studio-core": ["@prisma/studio-core@0.27.3", "", { "dependencies": { "@radix-ui/react-toggle": "1.1.10", "chart.js": "4.5.1" }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@scalar/openapi-types": ["@scalar/openapi-types@0.1.1", "", {}, "sha512-NMy3QNk6ytcCoPUGJH0t4NNr36OWXgZhA3ormr3TvhX1NDgoF95wFyodGVH8xiHeUyn2/FxtETm8UBLbB5xEmg=="], + + "@scalar/themes": ["@scalar/themes@0.9.86", "", { "dependencies": { "@scalar/types": "0.1.7" } }, "sha512-QUHo9g5oSWi+0Lm1vJY9TaMZRau8LHg+vte7q5BVTBnu6NuQfigCaN+ouQ73FqIVd96TwMO6Db+dilK1B+9row=="], + + "@scalar/types": ["@scalar/types@0.0.12", "", { "dependencies": { "@scalar/openapi-types": "0.1.1", "@unhead/schema": "^1.9.5" } }, "sha512-XYZ36lSEx87i4gDqopQlGCOkdIITHHEvgkuJFrXFATQs9zHARop0PN0g4RZYWj+ZpCUclOcaOjbCt8JGe22mnQ=="], + + "@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="], + + "@scure/bip32": ["@scure/bip32@1.7.0", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw=="], + + "@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="], + + "@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + + "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + + "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + + "@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="], + + "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], + + "@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA=="], + + "abitype": ["abitype@1.2.3", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "argon2": ["argon2@0.41.1", "", { "dependencies": { "@phc/format": "^1.0.0", "node-addon-api": "^8.1.0", "node-gyp-build": "^4.8.1" } }, "sha512-dqCW8kJXke8Ik+McUcMDltrbuAWETPyU6iq+4AhxqKphWi7pChB/Zgd/Tp/o8xRLbg8ksMj46F/vph9wnxpTzQ=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + + "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + + "axios": ["axios@0.30.3", "", { "dependencies": { "follow-redirects": "^1.15.4", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-5/tmEb6TmE/ax3mdXBc/Mi6YdPGxQsv+0p5YlciXWt3PHIn0VamqCXhRMtScnwY3lbgSXLneOuXAKUhgmSRpwg=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "better-result": ["better-result@2.9.2", "", {}, "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "bullmq": ["bullmq@5.76.10", "", { "dependencies": { "cron-parser": "4.9.0", "ioredis": "5.10.1", "msgpackr": "2.0.1", "node-abort-controller": "3.1.1", "semver": "7.8.0", "tslib": "2.8.1" } }, "sha512-LWve7SpQjYSpCP2GEsWmoyzTz2H37L8HRmSTu3YihYsTOr5kJxrfEX6aEV7m6eskEMWXSHZYTMZepX6qNaH6CQ=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "c12": ["c12@3.3.4", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.4", "defu": "^6.1.6", "dotenv": "^17.3.1", "exsolve": "^1.0.8", "giget": "^3.2.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "pkg-types": "^2.3.0", "rc9": "^3.0.1" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA=="], + + "cafe-utility": ["cafe-utility@33.9.0", "", {}, "sha512-MrTuoypjKKnfJg7CqbPbN2ugXuPp0qG5mLdJMjG9GzeGhMDBxD3xhM4EnmPh7+rH4sFqx29c4caxg3oU+eT/Zw=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], + + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + + "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "cron-parser": ["cron-parser@4.9.0", "", { "dependencies": { "luxon": "^3.2.1" } }, "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], + + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "effect": ["effect@3.20.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw=="], + + "elysia": ["elysia@1.4.28", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="], + + "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], + + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + + "fast-copy": ["fast-copy@3.0.2", "", {}, "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ=="], + + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], + + "file-type": ["file-type@22.0.1", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA=="], + + "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gaxios": ["gaxios@6.7.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "is-stream": "^2.0.0", "node-fetch": "^2.6.9", "uuid": "^9.0.1" } }, "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ=="], + + "gcp-metadata": ["gcp-metadata@6.1.1", "", { "dependencies": { "gaxios": "^6.1.1", "google-logging-utils": "^0.0.2", "json-bigint": "^1.0.0" } }, "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A=="], + + "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-port-please": ["get-port-please@3.2.0", "", {}, "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "giget": ["giget@3.2.0", "", { "bin": { "giget": "dist/cli.mjs" } }, "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A=="], + + "google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="], + + "google-logging-utils": ["google-logging-utils@0.0.2", "", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "grammex": ["grammex@3.1.12", "", {}, "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ=="], + + "graphmatch": ["graphmatch@1.1.1", "", {}, "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg=="], + + "gtoken": ["gtoken@7.1.0", "", { "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" } }, "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + + "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], + + "hono": ["hono@4.12.21", "", {}, "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ=="], + + "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], + + "http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="], + + "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], + + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "isomorphic-ws": ["isomorphic-ws@4.0.1", "", { "peerDependencies": { "ws": "*" } }, "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w=="], + + "isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], + + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], + + "lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="], + + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + + "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], + + "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="], + + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], + + "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], + + "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], + + "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "msgpackr": ["msgpackr@2.0.1", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], + + "mysql2": ["mysql2@3.15.3", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg=="], + + "named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="], + + "nanoid": ["nanoid@5.1.11", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg=="], + + "node-abort-controller": ["node-abort-controller@3.1.1", "", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="], + + "node-addon-api": ["node-addon-api@8.7.0", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + + "ox": ["ox@0.14.22", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-nb5msL8qWbPglhIfZbGJAfw3cqiJjFMiWmACt7kgyWtLib12tcctbHufMT9Hb0Lr6Pt4k9I3dbpueTpbhvbqvA=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], + + "perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="], + + "pg": ["pg@8.21.0", "", { "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.14.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA=="], + + "pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="], + + "pg-connection-string": ["pg-connection-string@2.13.0", "", {}, "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="], + + "pg-protocol": ["pg-protocol@1.14.0", "", {}, "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + + "pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="], + + "pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="], + + "pino-pretty": ["pino-pretty@11.3.0", "", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^3.0.2", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pump": "^3.0.0", "readable-stream": "^4.0.0", "secure-json-parse": "^2.4.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^3.1.1" }, "bin": { "pino-pretty": "bin.js" } }, "sha512-oXwn7ICywaZPHmu3epHGU2oJX4nPmKvHvB/bwrJHlGcbEWaVcotkpyVHMKLKmiVryWYByNp0jpgAcXpFJDXJzA=="], + + "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + + "pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], + + "postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="], + + "postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + + "prisma": ["prisma@7.8.0", "", { "dependencies": { "@prisma/config": "7.8.0", "@prisma/dev": "0.24.3", "@prisma/engines": "7.8.0", "@prisma/studio-core": "0.27.3", "mysql2": "3.15.3", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3", "typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw=="], + + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + + "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + + "rc9": ["rc9@3.0.1", "", { "dependencies": { "defu": "^6.1.6", "destr": "^2.0.5" } }, "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ=="], + + "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + + "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], + + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + + "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + + "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], + + "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], + + "remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="], + + "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + + "seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="], + + "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], + + "thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="], + + "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], + + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + + "valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="], + + "viem": ["viem@2.50.4", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.14.22", "ws": "8.20.1" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-rf98F4s3Vlb+uJZEKfay3IbBw3CNCbVtx5Y3UIljlO2tSX420g/J0WQSYsjzBSasUFgxgsXabji14O9kGbiqgg=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + + "zeptomatch": ["zeptomatch@2.1.0", "", { "dependencies": { "grammex": "^3.1.11", "graphmatch": "^1.1.0" } }, "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA=="], + + "zhead": ["zhead@2.2.4", "", {}, "sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@prisma/dev/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.8.0", "", { "dependencies": { "@prisma/debug": "7.8.0" } }, "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw=="], + + "@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.8.0", "", { "dependencies": { "@prisma/debug": "7.8.0" } }, "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw=="], + + "@prisma/get-platform/@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="], + + "@scalar/themes/@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, "sha512-irIDYzTQG2KLvFbuTI8k2Pz/R4JR+zUUSykVTbEMatkzMmVFnn1VzNSMlODbadycwZunbnL2tA27AXed9URVjw=="], + + "c12/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "ox/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="], + + "pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "viem/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="], + + "@scalar/themes/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-waiKk12cRCqyUCWTOX0K1WEVX46+hVUK+zRPzAahDJ7G0TApvbNkuy5wx7aoUyEk++HHde0XuQnshXnt8jsddA=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..bfd8e49 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +timeout = 120000 diff --git a/package.json b/package.json new file mode 100644 index 0000000..c9b2fe5 --- /dev/null +++ b/package.json @@ -0,0 +1,47 @@ +{ + "name": "hosted-gateway", + "version": "0.1.0", + "private": true, + "description": "Hosted multi-tenant frontend for the Token4Token inference network. One operator wallet; xBZZ-denominated off-chain ledger.", + "scripts": { + "dev:api": "bun run --watch src/api.ts", + "dev:worker": "bun run --watch src/worker.ts", + "start:api": "bun run src/api.ts", + "start:worker": "bun run src/worker.ts", + "db:generate": "bunx prisma generate", + "db:push": "bunx prisma db push", + "db:migrate": "bunx prisma migrate deploy", + "db:seed": "bun run prisma/seed/index.ts", + "test:e2e": "bun test --preload ./test/setup.ts ./test/**/*.e2e-spec.ts", + "typecheck": "bunx tsc --noEmit" + }, + "//comment": "Prisma 7 reads its config from prisma.config.ts at the package root, not from package.json#prisma.", + "dependencies": { + "@elysiajs/bearer": "^1.2.0", + "@elysiajs/cors": "^1.2.0", + "@elysiajs/swagger": "^1.2.0", + "@ethersphere/bee-js": "^11.0.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.0", + "@noble/hashes": "^1.8.0", + "@prisma/adapter-pg": "^7.0.0", + "@prisma/client": "^7.0.0", + "argon2": "^0.41.1", + "bullmq": "^5.34.0", + "elysia": "^1.2.25", + "google-auth-library": "^9.15.0", + "ioredis": "^5.6.0", + "jsonwebtoken": "^9.0.2", + "pino": "^9.5.0", + "pino-pretty": "^11.2.2", + "viem": "^2.21.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/jsonwebtoken": "^9.0.9", + "bun-types": "latest", + "prisma": "^7.0.0", + "typescript": "^5.7" + }, + "//prisma-config": "Prisma 7 — see prisma.config.ts" +} diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..c4a1c69 --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,14 @@ +// Prisma 7 config. Replaces the old `datasource.url` field in +// prisma/schema.prisma and the `package.json#prisma` key. See +// https://pris.ly/d/config-datasource. +import { defineConfig } from 'prisma/config'; + +export default defineConfig({ + schema: './prisma/schema.prisma', + datasource: { + url: process.env.DATABASE_URL, + }, + migrations: { + seed: 'bun run prisma/seed/index.ts', + }, +}); diff --git a/prisma/migrations/20260520091204_init/migration.sql b/prisma/migrations/20260520091204_init/migration.sql new file mode 100644 index 0000000..a349892 --- /dev/null +++ b/prisma/migrations/20260520091204_init/migration.sql @@ -0,0 +1,201 @@ +-- CreateEnum +CREATE TYPE "UserRole" AS ENUM ('USER', 'ADMIN'); + +-- CreateEnum +CREATE TYPE "AuthProvider" AS ENUM ('google', 'email'); + +-- CreateEnum +CREATE TYPE "AccountStatus" AS ENUM ('active', 'disabled'); + +-- CreateEnum +CREATE TYPE "LedgerKind" AS ENUM ('welcomeGrant', 'settle', 'refund', 'topup', 'adjustment'); + +-- CreateEnum +CREATE TYPE "GatewayJobStatus" AS ENUM ('posted', 'acked', 'delivered', 'claimed', 'cancelled', 'timed_out', 'failed'); + +-- CreateEnum +CREATE TYPE "TopupStatus" AS ENUM ('requires_payment', 'processing', 'succeeded', 'credited', 'failed', 'refunded'); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "email" TEXT NOT NULL, + "emailVerified" BOOLEAN NOT NULL DEFAULT false, + "verificationToken" TEXT, + "authProvider" "AuthProvider" NOT NULL, + "googleSub" TEXT, + "passwordHash" TEXT, + "role" "UserRole" NOT NULL DEFAULT 'USER', + "stripeCustomerId" TEXT, + "country" TEXT, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ApiKey" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "key" TEXT NOT NULL, + "name" TEXT NOT NULL DEFAULT '', + "lastUsedAt" TIMESTAMP(3), + "scopeMaxXbzzPerDay" DECIMAL(78,0), + "modelAllowlist" TEXT[] DEFAULT ARRAY[]::TEXT[], + "userId" TEXT NOT NULL, + + CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Account" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "status" "AccountStatus" NOT NULL DEFAULT 'active', + "balanceXbzzWei" DECIMAL(78,0) NOT NULL DEFAULT 0, + "reservedXbzzWei" DECIMAL(78,0) NOT NULL DEFAULT 0, + "userId" TEXT NOT NULL, + + CONSTRAINT "Account_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LedgerEntry" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "kind" "LedgerKind" NOT NULL, + "deltaXbzzWei" DECIMAL(78,0) NOT NULL, + "idempotencyKey" TEXT NOT NULL, + "note" TEXT, + "accountId" TEXT NOT NULL, + "gatewayJobId" TEXT, + "topupId" TEXT, + + CONSTRAINT "LedgerEntry_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "GatewayJob" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "routingId" TEXT NOT NULL, + "onChainJobId" TEXT, + "requestHash" TEXT, + "responseHash" TEXT, + "provider" TEXT NOT NULL, + "modelId" TEXT NOT NULL, + "estimatedMaxXbzzWei" DECIMAL(78,0) NOT NULL, + "actualXbzzWei" DECIMAL(78,0), + "status" "GatewayJobStatus" NOT NULL DEFAULT 'posted', + "postedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ackedAt" TIMESTAMP(3), + "deliveredAt" TIMESTAMP(3), + "claimedAt" TIMESTAMP(3), + "ackDeadline" TIMESTAMP(3), + "deliveryDeadline" TIMESTAMP(3), + "promptTokens" INTEGER, + "completionTokens" INTEGER, + "promptRedacted" TEXT, + "responseRedacted" TEXT, + "errorMessage" TEXT, + "userId" TEXT NOT NULL, + + CONSTRAINT "GatewayJob_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Topup" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "status" "TopupStatus" NOT NULL DEFAULT 'requires_payment', + "stripePaymentIntent" TEXT NOT NULL, + "amountUsdCents" INTEGER NOT NULL, + "creditedXbzzWei" DECIMAL(78,0), + "usdPerXbzzAtCredit" DECIMAL(18,8), + "error" TEXT, + "userId" TEXT NOT NULL, + + CONSTRAINT "Topup_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_verificationToken_key" ON "User"("verificationToken"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_googleSub_key" ON "User"("googleSub"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_stripeCustomerId_key" ON "User"("stripeCustomerId"); + +-- CreateIndex +CREATE INDEX "User_authProvider_googleSub_idx" ON "User"("authProvider", "googleSub"); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_key_key" ON "ApiKey"("key"); + +-- CreateIndex +CREATE INDEX "ApiKey_userId_idx" ON "ApiKey"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Account_userId_key" ON "Account"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "LedgerEntry_idempotencyKey_key" ON "LedgerEntry"("idempotencyKey"); + +-- CreateIndex +CREATE INDEX "LedgerEntry_accountId_createdAt_idx" ON "LedgerEntry"("accountId", "createdAt" DESC); + +-- CreateIndex +CREATE INDEX "LedgerEntry_kind_idx" ON "LedgerEntry"("kind"); + +-- CreateIndex +CREATE UNIQUE INDEX "GatewayJob_routingId_key" ON "GatewayJob"("routingId"); + +-- CreateIndex +CREATE UNIQUE INDEX "GatewayJob_onChainJobId_key" ON "GatewayJob"("onChainJobId"); + +-- CreateIndex +CREATE INDEX "GatewayJob_userId_createdAt_idx" ON "GatewayJob"("userId", "createdAt" DESC); + +-- CreateIndex +CREATE INDEX "GatewayJob_status_idx" ON "GatewayJob"("status"); + +-- CreateIndex +CREATE INDEX "GatewayJob_onChainJobId_idx" ON "GatewayJob"("onChainJobId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Topup_stripePaymentIntent_key" ON "Topup"("stripePaymentIntent"); + +-- CreateIndex +CREATE INDEX "Topup_userId_createdAt_idx" ON "Topup"("userId", "createdAt" DESC); + +-- CreateIndex +CREATE INDEX "Topup_status_idx" ON "Topup"("status"); + +-- AddForeignKey +ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_gatewayJobId_fkey" FOREIGN KEY ("gatewayJobId") REFERENCES "GatewayJob"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_topupId_fkey" FOREIGN KEY ("topupId") REFERENCES "Topup"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GatewayJob" ADD CONSTRAINT "GatewayJob_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Topup" ADD CONSTRAINT "Topup_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..7434f21 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,223 @@ +// 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? + + // 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]) +} diff --git a/prisma/seed/index.ts b/prisma/seed/index.ts new file mode 100644 index 0000000..45aad97 --- /dev/null +++ b/prisma/seed/index.ts @@ -0,0 +1,18 @@ +// Phase 1 has no seed data — accounts are created on signup. +// Placeholder for future Config table (e.g. operator metadata) or test fixtures. +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('No seed data to insert in v1.'); +} + +main() + .catch((err) => { + console.error(err); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/accounts/ledger.ts b/src/accounts/ledger.ts new file mode 100644 index 0000000..ff3e1d1 --- /dev/null +++ b/src/accounts/ledger.ts @@ -0,0 +1,145 @@ +import { Prisma } from '@prisma/client'; +import { prisma } from '../db'; +import { env } from '../env'; +import { logger } from '../logger'; + +/** + * Off-chain xBZZ ledger for tenant accounts. All deltas live in `LedgerEntry` + * (append-only, idempotent by `idempotencyKey`); `Account.balanceXbzzWei` and + * `Account.reservedXbzzWei` are denormalized running totals updated in the + * same transaction. + * + * Reservations are tracked as `Account.reservedXbzzWei` (a field on the + * account), not as ledger rows — the ledger means "money moved" not "money + * locked". On `JobClaimed` the reservation is released AND a `settle` ledger + * row debits the actual cost. + */ + +export class InsufficientBalanceError extends Error { + constructor() { + super('Insufficient xBZZ credit'); + } +} + +export async function creditWelcomeGrant(accountId: string): Promise { + const key = `welcome:${accountId}`; + try { + await prisma.$transaction(async (tx) => { + await tx.ledgerEntry.create({ + data: { + accountId, + kind: 'welcomeGrant', + deltaXbzzWei: new Prisma.Decimal(env.WELCOME_XBZZ_WEI), + idempotencyKey: key, + }, + }); + await tx.account.update({ + where: { id: accountId }, + data: { balanceXbzzWei: { increment: new Prisma.Decimal(env.WELCOME_XBZZ_WEI) } }, + }); + }); + logger.info({ accountId, xbzz: env.WELCOME_XBZZ_WEI }, 'welcome grant credited'); + } catch (err: any) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + // Unique constraint violation on idempotencyKey — already credited. + logger.debug({ accountId }, 'welcome grant already credited (idempotent)'); + return; + } + throw err; + } +} + +/** + * Reserve xBZZ for an in-flight chat completion. Atomic with `SELECT FOR UPDATE` + * to prevent two concurrent requests from over-spending the same user's balance. + * Throws `InsufficientBalanceError` if available < amount. + */ +export async function reserveXbzz(userId: string, amountWei: bigint): Promise { + await prisma.$transaction(async (tx) => { + // Postgres `SELECT FOR UPDATE` via raw SQL — Prisma's `update` is also + // atomic but doesn't expose the row-level lock semantics we want. + const rows = await tx.$queryRaw< + { id: string; balance_xbzz_wei: string; reserved_xbzz_wei: string; status: string }[] + >`SELECT id, "balanceXbzzWei" AS balance_xbzz_wei, "reservedXbzzWei" AS reserved_xbzz_wei, status::text AS status + FROM "Account" WHERE "userId" = ${userId} FOR UPDATE`; + const acc = rows[0]; + if (!acc) throw new Error('Account not found'); + if (acc.status !== 'active') throw new Error(`Account is ${acc.status}`); + + const balance = BigInt(acc.balance_xbzz_wei); + const reserved = BigInt(acc.reserved_xbzz_wei); + if (balance - reserved < amountWei) { + throw new InsufficientBalanceError(); + } + await tx.account.update({ + where: { id: acc.id }, + data: { reservedXbzzWei: { increment: new Prisma.Decimal(amountWei.toString()) } }, + }); + }); +} + +/** + * Settle a completed job: release the reservation and debit the actual cost. + * Idempotent via the `settle:` ledger key. + */ +export async function settleXbzz( + userId: string, + gatewayJobId: string, + estimateXbzzWei: bigint, + actualXbzzWei: bigint, +): Promise { + const key = `settle:${gatewayJobId}`; + try { + await prisma.$transaction(async (tx) => { + await tx.ledgerEntry.create({ + data: { + accountId: (await tx.account.findUniqueOrThrow({ where: { userId }, select: { id: true } })).id, + kind: 'settle', + deltaXbzzWei: new Prisma.Decimal((-actualXbzzWei).toString()), + gatewayJobId, + idempotencyKey: key, + }, + }); + await tx.account.update({ + where: { userId }, + data: { + balanceXbzzWei: { decrement: new Prisma.Decimal(actualXbzzWei.toString()) }, + reservedXbzzWei: { decrement: new Prisma.Decimal(estimateXbzzWei.toString()) }, + }, + }); + }); + } catch (err: any) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + logger.debug({ gatewayJobId }, 'settle already recorded (idempotent)'); + return; + } + throw err; + } +} + +/** + * Release a reservation without debiting (job cancelled or timed out). + * Idempotent via `release:` — but we don't write a ledger row, + * we only adjust `reservedXbzzWei` and rely on the GatewayJob status + * transition to mark the release as having happened. + */ +export async function releaseReservation( + userId: string, + gatewayJobId: string, + estimateXbzzWei: bigint, +): Promise { + await prisma.$transaction(async (tx) => { + const job = await tx.gatewayJob.findUniqueOrThrow({ + where: { id: gatewayJobId }, + select: { status: true }, + }); + // Only release once — `cancelled` / `timed_out` / `failed` are terminal. + if (job.status === 'cancelled' || job.status === 'timed_out' || job.status === 'failed') { + return; + } + await tx.account.update({ + where: { userId }, + data: { reservedXbzzWei: { decrement: new Prisma.Decimal(estimateXbzzWei.toString()) } }, + }); + }); +} diff --git a/src/accounts/processor.ts b/src/accounts/processor.ts new file mode 100644 index 0000000..45dadce --- /dev/null +++ b/src/accounts/processor.ts @@ -0,0 +1,36 @@ +import type { Job } from 'bullmq'; +import type { Account } from '@prisma/client'; +import { BaseProcessor } from '../queue/processor'; +import { prisma } from '../db'; +import { creditWelcomeGrant } from './ledger'; +import { logger } from '../logger'; + +const ACCOUNT_FIELDS = ['status', 'balanceXbzzWei', 'reservedXbzzWei'] as const; + +class AccountsProcessor extends BaseProcessor { + constructor() { + super('account', ACCOUNT_FIELDS); + } + + /** + * On account creation, credit the welcome grant — but only if the user's + * email is verified (Google sub is verified by default; email-signup users + * must complete the verify link first). + */ + protected async onCreated(_job: Job, account: Account): Promise { + const user = await prisma.user.findUniqueOrThrow({ + where: { id: account.userId }, + select: { emailVerified: true }, + }); + if (!user.emailVerified) { + logger.info( + { accountId: account.id, userId: account.userId }, + 'welcome grant deferred — email not verified', + ); + return; + } + await creditWelcomeGrant(account.id); + } +} + +export const accountsProcessor = new AccountsProcessor(); diff --git a/src/accounts/routes.ts b/src/accounts/routes.ts new file mode 100644 index 0000000..3803ce0 --- /dev/null +++ b/src/accounts/routes.ts @@ -0,0 +1,61 @@ +import Elysia from 'elysia'; +import { prisma } from '../db'; +import { jwtGuard } from '../auth/jwt'; +import { getUsdEquivalent } from '../pricing/usdDisplay'; + +export const accountsRoutes = new Elysia({ prefix: '/accounts' }) + .use(jwtGuard) + .get('/me', async ({ user }) => { + const account = await prisma.account.findUniqueOrThrow({ + where: { userId: user.userId }, + select: { + id: true, + status: true, + balanceXbzzWei: true, + reservedXbzzWei: true, + createdAt: true, + updatedAt: true, + }, + }); + + const balanceWei = BigInt(account.balanceXbzzWei.toString()); + const reservedWei = BigInt(account.reservedXbzzWei.toString()); + const available = balanceWei - reservedWei; + + const balanceUsd = await getUsdEquivalent(balanceWei).catch(() => null); + const availableUsd = await getUsdEquivalent(available).catch(() => null); + + return { + id: account.id, + status: account.status, + balanceXbzzWei: account.balanceXbzzWei.toString(), + reservedXbzzWei: account.reservedXbzzWei.toString(), + availableXbzzWei: available.toString(), + balanceUsd, // cosmetic; null if oracle unavailable + availableUsd, + createdAt: account.createdAt, + updatedAt: account.updatedAt, + }; + }) + .get('/me/ledger', async ({ user, query }) => { + const limit = Math.min(Number(query.limit ?? 50), 200); + const account = await prisma.account.findUniqueOrThrow({ + where: { userId: user.userId }, + select: { id: true }, + }); + const entries = await prisma.ledgerEntry.findMany({ + where: { accountId: account.id }, + orderBy: { createdAt: 'desc' }, + take: limit, + select: { + id: true, + kind: true, + deltaXbzzWei: true, + createdAt: true, + gatewayJobId: true, + topupId: true, + note: true, + }, + }); + return entries.map((e) => ({ ...e, deltaXbzzWei: e.deltaXbzzWei.toString() })); + }); diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..3f4127c --- /dev/null +++ b/src/api.ts @@ -0,0 +1,42 @@ +import { Elysia } from 'elysia'; +import { cors } from '@elysiajs/cors'; +import { swagger } from '@elysiajs/swagger'; +import { env } from './env'; +import { logger } from './logger'; +import { authRoutes } from './auth/routes'; +import { usersRoutes } from './users/routes'; +import { accountsRoutes } from './accounts/routes'; +import { apiKeysRoutes } from './apiKeys/routes'; +import { gatewayRoutes } from './gateway/routes'; +import { getGatewayContext } from './gateway/context'; + +const app = new Elysia() + .use(cors()) + .use(swagger()) + .onError(({ error, set }) => { + const status = set.status ?? 500; + const message = (error as Error).message ?? 'Internal error'; + logger.warn({ status, message }, 'request errored'); + return { error: message }; + }) + .get('/health', () => ({ ok: true, ts: Date.now() })) + .use(authRoutes) + .use(usersRoutes) + .use(accountsRoutes) + .use(apiKeysRoutes) + .use(gatewayRoutes); + +// Start the HTTP server immediately so auth / accounts / api-keys routes are +// reachable without waiting on Bee + Gnosis RPC. The gateway context is booted +// lazily (in the background) — if it fails, only `/v1/*` routes degrade. This +// makes auth-only test runs decouple from the chain/Bee stack. +app.listen(env.HOSTED_GATEWAY_PORT, () => { + logger.info({ port: env.HOSTED_GATEWAY_PORT }, 'hosted gateway api ready'); +}); + +getGatewayContext().catch((err) => { + logger.error( + { err: err.message }, + 'gateway context boot failed — /v1 chat completions will return 503 until this is resolved', + ); +}); diff --git a/src/apiKeys/routes.ts b/src/apiKeys/routes.ts new file mode 100644 index 0000000..ab6a2a4 --- /dev/null +++ b/src/apiKeys/routes.ts @@ -0,0 +1,60 @@ +import Elysia, { t } from 'elysia'; +import { prisma } from '../db'; +import { jwtGuard } from '../auth/jwt'; + +export const apiKeysRoutes = new Elysia({ prefix: '/api-keys' }) + .use(jwtGuard) + .get('/', async ({ user }) => { + const keys = await prisma.apiKey.findMany({ + where: { userId: user.userId }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + name: true, + createdAt: true, + lastUsedAt: true, + scopeMaxXbzzPerDay: true, + modelAllowlist: true, + }, + }); + // Never echo the secret value after creation. + return keys.map((k) => ({ + ...k, + scopeMaxXbzzPerDay: k.scopeMaxXbzzPerDay?.toString() ?? null, + })); + }) + .post( + '/', + async ({ user, body }) => { + const created = await prisma.apiKey.create({ + data: { + userId: user.userId, + name: body.name ?? '', + }, + }); + // Return the full key string ONCE; this is the only chance the user has + // to capture it. The dashboard MUST surface a "copy" affordance. + return { + id: created.id, + name: created.name, + key: `t4t-live-${created.key}`, + createdAt: created.createdAt, + }; + }, + { + body: t.Object({ name: t.Optional(t.String()) }), + }, + ) + .delete( + '/:id', + async ({ user, params, set }) => { + const row = await prisma.apiKey.findUnique({ where: { id: params.id } }); + if (!row || row.userId !== user.userId) { + set.status = 404; + throw new Error('API key not found'); + } + await prisma.apiKey.delete({ where: { id: params.id } }); + return { ok: true }; + }, + { params: t.Object({ id: t.String() }) }, + ); diff --git a/src/auth/google.ts b/src/auth/google.ts new file mode 100644 index 0000000..285516c --- /dev/null +++ b/src/auth/google.ts @@ -0,0 +1,37 @@ +import { OAuth2Client } from 'google-auth-library'; +import { env } from '../env'; + +const client = new OAuth2Client(); + +export interface GoogleProfile { + sub: string; // Google's stable user id + email: string; + emailVerified: boolean; + name?: string; + picture?: string; +} + +/** + * Verifies a Google ID token (from the One Tap / Sign in with Google flow on + * the client) and returns the verified profile. Throws on invalid token. + */ +export async function verifyGoogleIdToken(idToken: string): Promise { + if (!env.GOOGLE_OAUTH_CLIENT_ID) { + throw new Error('GOOGLE_OAUTH_CLIENT_ID is not configured'); + } + const ticket = await client.verifyIdToken({ + idToken, + audience: env.GOOGLE_OAUTH_CLIENT_ID, + }); + const payload = ticket.getPayload(); + if (!payload?.sub || !payload.email) { + throw new Error('Google id token missing required claims'); + } + return { + sub: payload.sub, + email: payload.email, + emailVerified: payload.email_verified === true, + name: payload.name, + picture: payload.picture, + }; +} diff --git a/src/auth/jwt.ts b/src/auth/jwt.ts new file mode 100644 index 0000000..e538c7b --- /dev/null +++ b/src/auth/jwt.ts @@ -0,0 +1,75 @@ +import jwt from 'jsonwebtoken'; +import Elysia from 'elysia'; +import { prisma } from '../db'; +import { env } from '../env'; +import type { UserRole } from '@prisma/client'; + +export interface JwtPayload { + userId: string; + role: UserRole; + email: string; +} + +export function signToken(payload: JwtPayload): string { + return jwt.sign(payload, env.JWT_SECRET_KEY, { expiresIn: '7d' }); +} + +export function verifyToken(token: string): JwtPayload { + return jwt.verify(token, env.JWT_SECRET_KEY) as JwtPayload; +} + +function extractBearer(header: string | null | undefined): string | null { + if (!header) return null; + const [scheme, token] = header.split(' '); + if (scheme !== 'Bearer' || !token) return null; + return token; +} + +/** + * Elysia plugin: resolves the authenticated user from a JWT or API key. + * Throws 401 on missing or invalid credentials. Mirrors + * cipherdolls/core/src/auth/jwt.ts (Bearer → JWT, then API-key fallback). + */ +export const jwtGuard = new Elysia({ name: 'jwtGuard' }).derive( + { as: 'scoped' }, + async ({ headers, set }) => { + const token = extractBearer(headers.authorization); + if (!token) { + set.status = 401; + throw new Error('Missing authorization token'); + } + + // Try JWT first + try { + const payload = verifyToken(token); + return { user: payload }; + } catch { + // not a valid JWT — fall through + } + + // Try API key (matches against the raw `t4t-live-` value) + const stripped = token.startsWith('t4t-live-') ? token.slice('t4t-live-'.length) : token; + const apiKey = await prisma.apiKey.findUnique({ + where: { key: stripped }, + include: { user: true }, + }); + + if (!apiKey) { + set.status = 401; + throw new Error('Invalid authorization token'); + } + + // Touch lastUsedAt; fire-and-forget. + prisma.apiKey + .update({ where: { id: apiKey.id }, data: { lastUsedAt: new Date() } }) + .catch(() => undefined); + + return { + user: { + userId: apiKey.user.id, + role: apiKey.user.role, + email: apiKey.user.email, + } satisfies JwtPayload, + }; + }, +); diff --git a/src/auth/routes.ts b/src/auth/routes.ts new file mode 100644 index 0000000..e7b05ed --- /dev/null +++ b/src/auth/routes.ts @@ -0,0 +1,139 @@ +import Elysia, { t } from 'elysia'; +import argon2 from 'argon2'; +import { randomBytes } from 'crypto'; +import { prisma, model } from '../db'; +import { signToken } from './jwt'; +import { verifyGoogleIdToken } from './google'; +import { logger } from '../logger'; + +const ECHO_VERIFICATION_TOKEN = process.env.NODE_ENV !== 'production'; + +export const authRoutes = new Elysia({ prefix: '/auth' }) + .post( + '/google', + async ({ body, set }) => { + const profile = await verifyGoogleIdToken(body.idToken).catch((err) => { + logger.warn({ err: err.message }, 'google id token verification failed'); + set.status = 401; + throw new Error('Invalid Google id token'); + }); + + // Upsert user + let user = await prisma.user.findUnique({ where: { googleSub: profile.sub } }); + if (!user) { + // Use model.user.create so the user.created queue job fires (which + // triggers wallet/account creation + welcome grant). + user = await model.user.create({ + data: { + email: profile.email, + emailVerified: profile.emailVerified, + authProvider: 'google', + googleSub: profile.sub, + }, + }); + } + + return { + token: signToken({ userId: user.id, role: user.role, email: user.email }), + user: { id: user.id, email: user.email, role: user.role }, + }; + }, + { + body: t.Object({ idToken: t.String() }), + }, + ) + + .post( + '/email/signup', + async ({ body, set }) => { + const existing = await prisma.user.findUnique({ where: { email: body.email } }); + if (existing) { + set.status = 409; + throw new Error('Email already registered'); + } + const passwordHash = await argon2.hash(body.password); + const verificationToken = randomBytes(32).toString('hex'); + const user = await model.user.create({ + data: { + email: body.email, + emailVerified: false, + authProvider: 'email', + passwordHash, + verificationToken, + }, + }); + + // TODO Phase 1.5: real outbound mailer. In dev/test we echo the token + // back so the client (or test harness) can finish the flow synchronously. + logger.info( + { userId: user.id, echoed: ECHO_VERIFICATION_TOKEN }, + 'email signup — verification token issued', + ); + + return { + token: signToken({ userId: user.id, role: user.role, email: user.email }), + user: { id: user.id, email: user.email, role: user.role, emailVerified: false }, + verificationToken: ECHO_VERIFICATION_TOKEN ? verificationToken : undefined, + }; + }, + { + body: t.Object({ + email: t.String({ format: 'email' }), + password: t.String({ minLength: 8 }), + }), + }, + ) + + .post( + '/email/verify', + async ({ body, set }) => { + const user = await prisma.user.findUnique({ + where: { verificationToken: body.token }, + }); + if (!user) { + set.status = 400; + throw new Error('Invalid or expired verification token'); + } + if (user.emailVerified) { + return { ok: true, alreadyVerified: true }; + } + // Flip via model.user.update so the user.updated job fires; the accounts + // processor watches the `emailVerified` field and credits the welcome + // grant when it flips false → true. + await model.user.update( + { + where: { id: user.id }, + data: { emailVerified: true, verificationToken: null }, + }, + user, + ); + return { ok: true }; + }, + { body: t.Object({ token: t.String() }) }, + ) + + .post( + '/email/login', + async ({ body, set }) => { + const user = await prisma.user.findUnique({ where: { email: body.email } }); + if (!user || !user.passwordHash) { + set.status = 401; + throw new Error('Invalid credentials'); + } + const ok = await argon2.verify(user.passwordHash, body.password); + if (!ok) { + set.status = 401; + throw new Error('Invalid credentials'); + } + return { + token: signToken({ userId: user.id, role: user.role, email: user.email }), + user: { id: user.id, email: user.email, role: user.role, emailVerified: user.emailVerified }, + }; + }, + { + body: t.Object({ + email: t.String({ format: 'email' }), + password: t.String(), + }), + }, + ); diff --git a/src/chain/balanceMonitor.ts b/src/chain/balanceMonitor.ts new file mode 100644 index 0000000..900a15b --- /dev/null +++ b/src/chain/balanceMonitor.ts @@ -0,0 +1,56 @@ +/** + * Polls the operator wallet's xBZZ + xDAI balances and alerts on low headroom. + * The operator wallet backs ALL virtual xBZZ on user accounts; if its xBZZ + * float runs dry, every chat completion fails at `postJob`. xDAI runs gas. + * + * v1 just logs at WARN; Phase 2 wires a Slack / PagerDuty webhook. + */ +import { erc20Abi } from 'viem'; +import { logger } from '../logger'; +import { getGatewayContext } from '../gateway/context'; + +const POLL_INTERVAL_MS = 5 * 60_000; +const LOW_XBZZ_THRESHOLD_WEI = 100n * 10n ** 18n; // 100 xBZZ +const LOW_XDAI_THRESHOLD_WEI = 1n * 10n ** 17n; // 0.1 xDAI + +export function startBalanceMonitor(): void { + setInterval(() => { + poll().catch((err) => logger.warn({ err: err.message }, 'balance monitor tick failed')); + }, POLL_INTERVAL_MS).unref(); + // Fire once on startup so the first reading is in the logs immediately. + poll().catch((err) => logger.warn({ err: err.message }, 'initial balance monitor tick failed')); + logger.info('balance monitor started'); +} + +async function poll(): Promise { + const ctx = await getGatewayContext(); + const xbzz = await ctx.chain.pub.readContract({ + address: ctx.chain.xbzz, + abi: erc20Abi, + functionName: 'balanceOf', + args: [ctx.chain.address], + }); + const xdai = await ctx.chain.pub.getBalance({ address: ctx.chain.address }); + + logger.info( + { + operator: ctx.chain.address, + xbzzWei: (xbzz as bigint).toString(), + xdaiWei: xdai.toString(), + }, + 'operator wallet balances', + ); + + if ((xbzz as bigint) < LOW_XBZZ_THRESHOLD_WEI) { + logger.warn( + { xbzzWei: (xbzz as bigint).toString(), thresholdWei: LOW_XBZZ_THRESHOLD_WEI.toString() }, + 'operator xBZZ balance is low — refill the float', + ); + } + if (xdai < LOW_XDAI_THRESHOLD_WEI) { + logger.warn( + { xdaiWei: xdai.toString(), thresholdWei: LOW_XDAI_THRESHOLD_WEI.toString() }, + 'operator xDAI balance is low — refill for gas', + ); + } +} diff --git a/src/chain/claimWatcher.ts b/src/chain/claimWatcher.ts new file mode 100644 index 0000000..73ad7d8 --- /dev/null +++ b/src/chain/claimWatcher.ts @@ -0,0 +1,108 @@ +/** + * Polls `JobEscrow.JobClaimed` events (the on-chain source of truth for + * `actualPayment`) and settles the off-chain ledger by joining on + * `GatewayJob.onChainJobId`. + * + * Leader-locked via `withLock('watcher:claim')` so multiple worker replicas + * don't double-settle. Uses `getContractEvents` over a sliding [last, current] + * block window — survives stateless public RPCs that forget filter ids + * (rpc.gnosischain.com behavior). + */ +import type { Hex } from 'viem'; +import { jobEscrowAbi } from '../t4t/lib/abi'; +import { withLock } from '../queue/lock'; +import { redisConnection } from '../queue/connection'; +import { logger } from '../logger'; +import { prisma } from '../db'; +import { settleXbzz } from '../accounts/ledger'; +import { applyMarkup } from '../pricing/estimate'; +import { getGatewayContext } from '../gateway/context'; + +const POLL_INTERVAL_MS = 10_000; +const LEADER_LOCK = 'watcher:claim'; +const LEADER_TTL_MS = 30_000; +const LAST_BLOCK_KEY = 'watcher:claim:lastBlock'; + +export function startClaimWatcher(): void { + setInterval(() => { + tick().catch((err) => logger.warn({ err: err.message }, 'claimWatcher tick failed')); + }, POLL_INTERVAL_MS).unref(); + logger.info('claimWatcher started'); +} + +async function tick(): Promise { + // Run only if we hold the leader lock; otherwise another replica is the + // active watcher. withLock retries internally, which is fine — the interval + // tick is idempotent. + await withLock(LEADER_LOCK, doPoll, LEADER_TTL_MS); +} + +async function doPoll(): Promise { + const ctx = await getGatewayContext(); + const chain = ctx.chain; + + const stored = await redisConnection.get(LAST_BLOCK_KEY); + let lastBlock: bigint; + if (stored) { + lastBlock = BigInt(stored); + } else { + lastBlock = await chain.pub.getBlockNumber(); + await redisConnection.set(LAST_BLOCK_KEY, lastBlock.toString()); + } + + const current = await chain.pub.getBlockNumber(); + if (current <= lastBlock) return; + + // Cap each batch to 5_000 blocks so we don't hammer the RPC after a long + // restart pause (5_000 blocks at ~5s/block ≈ 7 hours of lookback). + const toBlock = current > lastBlock + 5_000n ? lastBlock + 5_000n : current; + + // JobClaimed → settle ledger. The JobEscrow ABI only exposes JobPosted and + // JobClaimed events; cancel/timeout settlement is reconciled in-process by + // the failure timers in `gateway/context.ts`. A future contract revision + // can add `JobCancelled` / `JobTimedOut` and this watcher will pick them up + // — for now the only event we tail is JobClaimed. + const claimed = await chain.pub.getContractEvents({ + address: chain.escrow, + abi: jobEscrowAbi, + eventName: 'JobClaimed', + fromBlock: lastBlock + 1n, + toBlock, + }); + for (const ev of claimed) { + await handleClaimed(ev.args.jobId as Hex | undefined, ev.args.paid as bigint | undefined); + } + + await redisConnection.set(LAST_BLOCK_KEY, toBlock.toString()); +} + +async function handleClaimed(jobId: Hex | undefined, paid: bigint | undefined): Promise { + if (!jobId || paid === undefined) return; + const job = await prisma.gatewayJob.findUnique({ + where: { onChainJobId: jobId }, + select: { id: true, userId: true, status: true, estimatedMaxXbzzWei: true }, + }); + if (!job) { + logger.debug({ jobId }, 'JobClaimed for unknown onChainJobId — skipping'); + return; + } + if (job.status === 'claimed') return; // idempotent + + const estimateWithMarkup = BigInt(job.estimatedMaxXbzzWei.toString()); + const actualWithMarkup = applyMarkup(paid); + + await settleXbzz(job.userId, job.id, estimateWithMarkup, actualWithMarkup); + await prisma.gatewayJob.update({ + where: { id: job.id }, + data: { + status: 'claimed', + actualXbzzWei: actualWithMarkup.toString(), + claimedAt: new Date(), + }, + }); + logger.info( + { gatewayJobId: job.id, userId: job.userId, paid: paid.toString(), actualWithMarkup: actualWithMarkup.toString() }, + 'JobClaimed settled', + ); +} + diff --git a/src/chain/operatorChain.ts b/src/chain/operatorChain.ts new file mode 100644 index 0000000..0356338 --- /dev/null +++ b/src/chain/operatorChain.ts @@ -0,0 +1,47 @@ +/** + * Operator-wallet chain writes, serialized via `withLock('chain:operator')`. + * + * One operator wallet = one nonce stream. Many concurrent users hitting + * `/v1/chat/completions` means many `postJob` / `ensureAllowance` calls fan in; + * without serialization, viem submits them concurrently and the second tx + * collides on nonce. The lock holds only for submission (until the tx hash + * comes back), not until the receipt — so this never bottlenecks PSS-flight or + * Swarm transfers. + */ +import type { Hex } from 'viem'; +import type { Address } from 'viem'; +import type { ChainClient } from '../t4t/lib/chain'; +import { + cancelJob as cancelJobRaw, + ensureAllowance as ensureAllowanceRaw, + postJob as postJobRaw, + timeoutJob as timeoutJobRaw, +} from '../t4t/lib/chain'; +import { withLock } from '../queue/lock'; + +const OPERATOR_LOCK = 'chain:operator'; + +export function ensureAllowance(c: ChainClient, spender: Address, needed: bigint): Promise { + return withLock(OPERATOR_LOCK, () => ensureAllowanceRaw(c, spender, needed)); +} + +export function postJob( + c: ChainClient, + args: { + provider: Address; + requestHash: Hex; + modelId: string; + maxPayment: bigint; + deliveryDeadline: number; + }, +): Promise<{ txHash: Hex; jobId: Hex }> { + return withLock(OPERATOR_LOCK, () => postJobRaw(c, args)); +} + +export function cancelJob(c: ChainClient, jobId: Hex): Promise { + return withLock(OPERATOR_LOCK, () => cancelJobRaw(c, jobId)); +} + +export function timeoutJob(c: ChainClient, jobId: Hex): Promise { + return withLock(OPERATOR_LOCK, () => timeoutJobRaw(c, jobId)); +} diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..2709973 --- /dev/null +++ b/src/db.ts @@ -0,0 +1,34 @@ +// `model..{create,update,delete}` wrappers that persist via Prisma AND +// enqueue a BullMQ job atomically. Raw reads still go through `prisma.x`. +// +// Pattern ported from cipherdolls/core/src/db.ts. + +import { PrismaClient } from '@prisma/client'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { createCudService } from './queue/service'; +import { env } from './env'; + +// Make BigInt JSON-serializable (used by BullMQ when stringifying job payloads). +// Safe because every BigInt we send through queues is xBZZ wei or token counts, +// neither of which loses precision when stringified. +(BigInt.prototype as any).toJSON = function () { + return this.toString(); +}; + +// Prisma 7 ships the query engine as a driver adapter — `@prisma/adapter-pg` +// wraps node-postgres. The schema no longer carries a `datasource.url`; CLI +// tools (migrate, generate, db push) read from prisma.config.ts, while the +// runtime client receives the adapter here. +const adapter = new PrismaPg({ connectionString: env.DATABASE_URL }); +export const prisma = new PrismaClient({ adapter }); + +// Each delegate is wrapped to: persist via Prisma → enqueue `created/updated/deleted`. +// The queue name MUST match the BullMQ worker registered in src/queue/startup.ts. +export const model = { + user: createCudService('user', prisma.user), + account: createCudService('account', prisma.account), + apiKey: createCudService('apiKey', prisma.apiKey), + ledgerEntry: createCudService('ledgerEntry', prisma.ledgerEntry), + gatewayJob: createCudService('gatewayJob', prisma.gatewayJob), + topup: createCudService('topup', prisma.topup), +}; diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..b7f4cd1 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,85 @@ +import { z } from 'zod'; + +const HexAddress = z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, 'must be a 0x-prefixed 20-byte hex address'); + +const HexPrivateKey = z + .string() + .regex(/^0x[a-fA-F0-9]{64}$/, 'must be a 0x-prefixed 32-byte hex key'); + +const HexBatchId = z + .string() + .regex(/^[a-fA-F0-9]{64}$/, 'must be a 32-byte hex postage batch id') + .optional() + .or(z.literal('')); + +const BigIntString = z + .string() + .regex(/^\d+$/, 'must be a non-negative integer (base 10)'); + +const EnvSchema = z.object({ + // Postgres / Redis + DATABASE_URL: z.string().url(), + REDIS_HOST: z.string().default('localhost'), + REDIS_PORT: z.coerce.number().int().positive().default(6379), + + // Auth + JWT_SECRET_KEY: z.string().min(16, 'JWT_SECRET_KEY must be at least 16 chars'), + GOOGLE_OAUTH_CLIENT_ID: z.string().optional(), + + // Operator wallet (the only on-chain identity) + OPERATOR_PRIVATE_KEY: HexPrivateKey, + GNOSIS_RPC_URL: z.string().url(), + + // T4T protocol contract addresses (on Gnosis Chain) + REGISTRY_ADDRESS: HexAddress, + ESCROW_ADDRESS: HexAddress, + XBZZ_ADDRESS: HexAddress, + + // Bee node + postage + BEE_API_URL: z.string().url(), + BEE_POSTAGE_BATCH_ID: HexBatchId, + BEE_STAMP_MANAGE: z + .union([z.string(), z.boolean()]) + .transform((v) => v === true || v === 'true' || v === '1') + .default('false'), + BEE_STAMP_LABEL: z.string().default('hosted-gateway'), + BEE_STAMP_TTL_DAYS: z.coerce.number().int().positive().default(30), + + // Pricing + MARKUP_BPS: z.coerce.number().int().min(0).max(10_000).default(2_000), + WELCOME_XBZZ_WEI: BigIntString.default('50000000000000000000'), // 50 xBZZ + + // Concurrency + MAX_CONCURRENT_JOBS: z.coerce.number().int().positive().default(200), + + // Server + HOSTED_GATEWAY_PORT: z.coerce.number().int().positive().default(8080), + LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), + + // Phase 2 — Stripe (optional) + STRIPE_SECRET_KEY: z.string().optional(), + STRIPE_WEBHOOK_SECRET: z.string().optional(), +}); + +export type Env = z.infer & { + WELCOME_XBZZ_WEI_BIGINT: bigint; +}; + +function loadEnv(): Env { + const parsed = EnvSchema.safeParse(process.env); + if (!parsed.success) { + console.error('Invalid environment configuration:'); + for (const issue of parsed.error.issues) { + console.error(` ${issue.path.join('.')}: ${issue.message}`); + } + process.exit(1); + } + return { + ...parsed.data, + WELCOME_XBZZ_WEI_BIGINT: BigInt(parsed.data.WELCOME_XBZZ_WEI), + }; +} + +export const env: Env = loadEnv(); diff --git a/src/gateway/context.ts b/src/gateway/context.ts new file mode 100644 index 0000000..afaa9e4 --- /dev/null +++ b/src/gateway/context.ts @@ -0,0 +1,335 @@ +/** + * Boots singleton state for the gateway engine: ChainClient (operator wallet), + * Bee + PssTransport, postage batch, ECIES cipher with operator PSS keypair, + * ModelDiscovery cache, and the in-memory `pending` / `failureTimers` / + * `jobMeta` maps that `handleChat` resolves against. + * + * Called once at startup from `src/api.ts`. The PSS subscription is started + * here so `job_ack` / `job_deliver` envelopes flow into `pending` / DB rows. + */ +import { Bee } from '@ethersphere/bee-js'; +import { keccak256, toBytes, type Hex } from 'viem'; +import { makeChain, type ChainClient } from '../t4t/lib/chain'; +import { discoverUsableBatchId, downloadChunk, PssTransport } from '../t4t/lib/swarm'; +import { ensureManagedStamp, topUpIfBelow, type ManagedStamp } from '../t4t/lib/stamps'; +import { clientTopic } from '../t4t/lib/envelope'; +import { EciesCipher, jsonDecrypt } from '../t4t/lib/crypto'; +import { generatePssKeypair } from '../t4t/lib/keys'; +import { ModelDiscovery } from '../t4t/modes/gateway/models'; +import type { + JobAckBody, + JobDeliverBody, + OpenAIChatResponse, + ResponsePayload, +} from '../t4t/lib/types'; +import { cancelJob, timeoutJob } from '../chain/operatorChain'; +import { env } from '../env'; +import { logger } from '../logger'; +import { prisma } from '../db'; +import { releaseReservation } from '../accounts/ledger'; +import { ACK_WINDOW_SECONDS } from '../t4t/lib/chain'; + +const FAILURE_GRACE_SECONDS = 5; + +export interface PendingSlot { + resolve: (r: OpenAIChatResponse) => void; + reject: (e: unknown) => void; +} + +export interface FailureSlot { + onChainJobId: Hex; + deliveryDeadline: number; + cancelTimer?: ReturnType; + timeoutTimer?: ReturnType; +} + +export interface JobMetaSlot { + userId: string; + gatewayJobId: string; + estimateMaxXbzzWei: bigint; + provider: string; + modelId: string; +} + +export interface GatewayContext { + bee: Bee; + chain: ChainClient; + postageBatchId: string; + cipher: EciesCipher; + pss: PssTransport; + pssPubKeyX: Hex; + selfOverlay: Hex; + discovery: ModelDiscovery; + pending: Map; + failureTimers: Map; + jobMeta: Map; + clearFailureTimers: (routing: Hex) => void; + rejectAndCleanup: (routing: Hex, err: Error) => void; + scheduleAckTimeout: (routing: Hex, slot: FailureSlot) => void; +} + +let bootPromise: Promise | null = null; + +export function getGatewayContext(): Promise { + if (!bootPromise) bootPromise = bootGatewayContext(); + return bootPromise; +} + +async function bootGatewayContext(): Promise { + const bee = new Bee(env.BEE_API_URL); + + // Resolve the operator's postage batch (env-pinned, managed, or discovered). + const postageBatchId = await resolvePostageBatch(bee); + + const chain = makeChain({ + rpcUrl: env.GNOSIS_RPC_URL, + privateKey: env.OPERATOR_PRIVATE_KEY as Hex, + registry: env.REGISTRY_ADDRESS as Hex, + escrow: env.ESCROW_ADDRESS as Hex, + xbzz: env.XBZZ_ADDRESS as Hex, + }); + + // Operator PSS keypair — non-persistent for now; tenants reuse this single + // operator key (providers see all jobs as coming from the same client PSS + // identity, which matches the on-chain wallet model). + const pssKeys = generatePssKeypair(); + + const cipher = new EciesCipher(pssKeys.privateKey); + const selfOverlay = await resolveSelfOverlay(bee); + const pss = new PssTransport({ bee, postageBatchId, logger, selfAddress: chain.address }); + + const discovery = new ModelDiscovery({ + chain, + minProvidersPerModel: 1, + cacheTtlSeconds: 30, + }); + + const pending = new Map(); + const failureTimers = new Map(); + const jobMeta = new Map(); + + function clearFailureTimers(routing: Hex): void { + const slot = failureTimers.get(routing); + if (!slot) return; + if (slot.cancelTimer) clearTimeout(slot.cancelTimer); + if (slot.timeoutTimer) clearTimeout(slot.timeoutTimer); + failureTimers.delete(routing); + } + + function rejectAndCleanup(routing: Hex, err: Error): void { + pending.get(routing)?.reject(err); + pending.delete(routing); + jobMeta.delete(routing); + clearFailureTimers(routing); + } + + async function onAckTimeout(routing: Hex): Promise { + const slot = failureTimers.get(routing); + const meta = jobMeta.get(routing); + if (!slot || !meta) return; + try { + const tx = await cancelJob(chain, slot.onChainJobId); + logger.warn({ tx, routing, onChainJobId: slot.onChainJobId }, 'no ACK — cancelled on-chain'); + await markGatewayJob(meta.gatewayJobId, 'cancelled', 'no PSS ack within ACK_WINDOW'); + await releaseReservation(meta.userId, meta.gatewayJobId, meta.estimateMaxXbzzWei); + rejectAndCleanup(routing, new Error('provider failed to ACK within window')); + } catch (err) { + logger.warn({ err, routing }, 'cancelJob failed — provider likely acked mid-flight'); + } + } + + async function onDeliveryTimeout(routing: Hex): Promise { + const slot = failureTimers.get(routing); + const meta = jobMeta.get(routing); + if (!slot || !meta) return; + try { + const tx = await timeoutJob(chain, slot.onChainJobId); + logger.warn({ tx, routing, onChainJobId: slot.onChainJobId }, 'delivery missed — timeoutJob'); + await markGatewayJob(meta.gatewayJobId, 'timed_out', 'no PSS delivery before deadline'); + await releaseReservation(meta.userId, meta.gatewayJobId, meta.estimateMaxXbzzWei); + rejectAndCleanup(routing, new Error('provider failed to deliver before deadline')); + return; + } catch (err) { + logger.warn({ err, routing }, 'timeoutJob reverted — falling back to cancelJob'); + } + try { + const tx = await cancelJob(chain, slot.onChainJobId); + logger.warn({ tx, routing, onChainJobId: slot.onChainJobId }, 'cancelJob fallback applied'); + await markGatewayJob(meta.gatewayJobId, 'cancelled', 'no PSS delivery; cancelJob fallback'); + await releaseReservation(meta.userId, meta.gatewayJobId, meta.estimateMaxXbzzWei); + rejectAndCleanup(routing, new Error('provider failed to deliver before deadline')); + } catch (err) { + logger.error({ err, routing }, 'both timeoutJob and cancelJob failed — job stuck in escrow'); + rejectAndCleanup(routing, new Error('delivery deadline missed; settlement failed')); + } + } + + function scheduleAckTimeout(routing: Hex, slot: FailureSlot): void { + slot.cancelTimer = setTimeout(() => { + onAckTimeout(routing).catch((err) => + logger.error({ err, routing }, 'onAckTimeout threw'), + ); + }, (ACK_WINDOW_SECONDS + FAILURE_GRACE_SECONDS) * 1000); + } + + // Subscribe to the operator's client topic — ALL tenants' jobs come back + // here (because all jobs go on-chain as the operator wallet). + pss.subscribe({ + topic: clientTopic(chain.address), + onEnvelope: async (envelope) => { + if (envelope.type === 'job_ack') { + const body = envelope.body as JobAckBody; + logger.info({ jobId: body.jobId, eta: body.estimatedCompletion }, 'ack received'); + const slot = failureTimers.get(body.jobId); + const meta = jobMeta.get(body.jobId); + if (slot) { + if (slot.cancelTimer) { + clearTimeout(slot.cancelTimer); + slot.cancelTimer = undefined; + } + if (!slot.timeoutTimer) { + const msUntilTimeout = Math.max( + 0, + (slot.deliveryDeadline + FAILURE_GRACE_SECONDS) * 1000 - Date.now(), + ); + slot.timeoutTimer = setTimeout(() => { + onDeliveryTimeout(body.jobId).catch((err) => + logger.error({ err, routing: body.jobId }, 'onDeliveryTimeout threw'), + ); + }, msUntilTimeout); + } + } + if (meta) { + await markGatewayJob(meta.gatewayJobId, 'acked', null, { ackedAt: new Date() }); + } + } else if (envelope.type === 'job_deliver') { + const body = envelope.body as JobDeliverBody; + const slot = pending.get(body.jobId); + const meta = jobMeta.get(body.jobId); + if (!slot) return; + try { + const bytes = await downloadChunk({ bee, postageBatchId, logger }, body.responseHash); + const payload = await jsonDecrypt(cipher, bytes); + if (meta) { + await markGatewayJob(meta.gatewayJobId, 'delivered', null, { + deliveredAt: new Date(), + responseHash: body.responseHash, + promptTokens: payload.openaiResponse.usage?.prompt_tokens ?? null, + completionTokens: payload.openaiResponse.usage?.completion_tokens ?? null, + }); + } + slot.resolve(payload.openaiResponse); + } catch (err) { + slot.reject(err); + } finally { + pending.delete(body.jobId); + jobMeta.delete(body.jobId); + clearFailureTimers(body.jobId); + } + } + }, + }); + + logger.info( + { + operator: chain.address, + postageBatchId, + pssPubKey: pssKeys.publicKeyX, + selfOverlay, + }, + 'gateway context booted', + ); + + return { + bee, + chain, + postageBatchId, + cipher, + pss, + pssPubKeyX: pssKeys.publicKeyX, + selfOverlay, + discovery, + pending, + failureTimers, + jobMeta, + clearFailureTimers, + rejectAndCleanup, + scheduleAckTimeout, + }; +} + +async function resolvePostageBatch(bee: Bee): Promise { + if (env.BEE_POSTAGE_BATCH_ID) return env.BEE_POSTAGE_BATCH_ID; + if (env.BEE_STAMP_MANAGE) { + const managed: ManagedStamp = await ensureManagedStamp({ + bee, + logger, + opts: { + depth: 22, + ttlDays: env.BEE_STAMP_TTL_DAYS, + minTtlDays: 7, + label: env.BEE_STAMP_LABEL, + dryRun: false, + }, + }); + // Re-check every 5 min (matches t4t-container's gateway tick). + setInterval(() => { + topUpIfBelow({ + bee, + logger, + batchId: managed.batchID, + ttlDays: env.BEE_STAMP_TTL_DAYS, + minTtlDays: 7, + maxUtilization: 0.7, + maxDepth: 28, + dryRun: false, + }).catch((err) => logger.warn({ err }, 'stamp tick failed')); + }, 300_000).unref(); + return managed.batchID; + } + const discovered = await discoverUsableBatchId(bee); + if (!discovered) { + throw new Error( + 'No usable postage batch on the Bee node. Set BEE_POSTAGE_BATCH_ID or BEE_STAMP_MANAGE=true.', + ); + } + return discovered; +} + +async function resolveSelfOverlay(bee: Bee): Promise { + const info = await bee.getNodeAddresses().catch(() => null); + const overlay = info?.overlay?.toString() ?? '00'.repeat(32); + return ('0x' + overlay.replace(/^0x/, '')) as Hex; +} + +/** Update a GatewayJob row. Direct prisma write (not model.gatewayJob.update) + * because we don't need fan-out for status transitions in v1. */ +async function markGatewayJob( + id: string, + status: + | 'posted' + | 'acked' + | 'delivered' + | 'claimed' + | 'cancelled' + | 'timed_out' + | 'failed', + errorMessage: string | null, + extra: Record = {}, +): Promise { + await prisma.gatewayJob + .update({ + where: { id }, + data: { + status, + errorMessage: errorMessage ?? undefined, + ...extra, + }, + }) + .catch((err) => logger.warn({ err: err.message, id, status }, 'gatewayJob update failed')); +} + +/** Util: derive the routing id (mirrors handleChat). Exposed for tests. */ +export function deriveRoutingId(requestHashHex: string): Hex { + return keccak256(toBytes('0x' + requestHashHex.replace(/^0x/, ''))); +} diff --git a/src/gateway/handleChat.ts b/src/gateway/handleChat.ts new file mode 100644 index 0000000..309a735 --- /dev/null +++ b/src/gateway/handleChat.ts @@ -0,0 +1,183 @@ +/** + * Refactor of t4t/container/src/modes/gateway/index.ts `handleChat`. The only + * functional change is that the persisted GatewayJob row carries `userId` and + * `estimatedMaxXbzzWei` so the claim watcher can settle the off-chain ledger + * against the right tenant. Provider selection, on-chain interaction, PSS + * notify, response wait — all unchanged. + */ +import { keccak256, toBytes, toHex, type Hex } from 'viem'; +import { jsonEncrypt } from '../t4t/lib/crypto'; +import { uploadChunk } from '../t4t/lib/swarm'; +import { signEnvelope, providerTopic } from '../t4t/lib/envelope'; +import { selectProvider } from '../t4t/modes/gateway/selector'; +import type { + JobNotifyBody, + OpenAIChatRequest, + OpenAIChatResponse, + RequestPayload, +} from '../t4t/lib/types'; +import { ACK_WINDOW_SECONDS } from '../t4t/lib/chain'; +import { ensureAllowance, postJob } from '../chain/operatorChain'; +import { prisma } from '../db'; +import { env } from '../env'; +import { logger } from '../logger'; +import { estimateMaxXbzz, type ChatRequest } from '../pricing/estimate'; +import { releaseReservation, reserveXbzz } from '../accounts/ledger'; +import type { GatewayContext, FailureSlot } from './context'; + +const PROTOCOL_VERSION = 1 as const; +const DEFAULT_DEADLINE_SECONDS = 300; + +export interface HandleChatArgs { + ctx: GatewayContext; + userId: string; + req: OpenAIChatRequest; +} + +export interface HandleChatResult { + gatewayJobId: string; + response: OpenAIChatResponse; +} + +export async function handleChat(args: HandleChatArgs): Promise { + const { ctx, userId, req } = args; + + // 1) Select provider (stateless registry scan against the operator's chain + // client; selector reads from chain on each call). + const target = await selectProvider(ctx.chain, 'top_rep_cheapest', { + modelId: req.model, + }); + if (!target) throw new Error(`no provider matches model=${req.model}`); + + // 2) Estimate cost — raw (posted on-chain as maxPayment) vs with-markup + // (reserved off-chain; the operator keeps the markup). + const { rawWei, withMarkupWei } = estimateMaxXbzz(req as ChatRequest, target.offering); + + // 3) Reserve off-chain BEFORE doing any chain work. If this throws (402), no + // on-chain side effect happened — fail-closed. + await reserveXbzz(userId, withMarkupWei); + + // 4) Allowance + on-chain postJob. Both serialized by `withLock('chain:operator')`. + let releasedReservation = false; + let gatewayJobId: string | null = null; + try { + await ensureAllowance(ctx.chain, ctx.chain.escrow, rawWei); + + const reqPayload: RequestPayload = { + v: PROTOCOL_VERSION, + jobId: toHex(crypto.getRandomValues(new Uint8Array(32))), + client: ctx.chain.address, + modelId: req.model, + openaiRequest: req, + clientPssPubKey: ctx.pssPubKeyX, + ts: Math.floor(Date.now() / 1000), + }; + const encrypted = await jsonEncrypt(ctx.cipher, target.provider.pssPublicKey, reqPayload); + const requestHash = await uploadChunk( + { bee: ctx.bee, postageBatchId: ctx.postageBatchId, logger }, + encrypted, + ); + + const deliveryDeadline = Math.floor(Date.now() / 1000) + DEFAULT_DEADLINE_SECONDS; + const { txHash, jobId: onChainJobId } = await postJob(ctx.chain, { + provider: target.provider.owner, + requestHash: ('0x' + requestHash) as Hex, + modelId: req.model, + maxPayment: rawWei, + deliveryDeadline, + }); + logger.info({ txHash, onChainJobId, provider: target.provider.owner, userId }, 'job posted on-chain'); + + const jobIdRouting = keccak256(toBytes('0x' + requestHash)); + + // Persist the GatewayJob row (tenant attribution lives here). + const created = await prisma.gatewayJob.create({ + data: { + routingId: jobIdRouting, + onChainJobId, + requestHash, + provider: target.provider.owner, + modelId: req.model, + estimatedMaxXbzzWei: withMarkupWei.toString(), + status: 'posted', + postedAt: new Date(), + deliveryDeadline: new Date(deliveryDeadline * 1000), + userId, + }, + }); + gatewayJobId = created.id; + + ctx.jobMeta.set(jobIdRouting, { + userId, + gatewayJobId: created.id, + estimateMaxXbzzWei: withMarkupWei, + provider: target.provider.owner, + modelId: req.model, + }); + + // 5) PSS-notify the provider out-of-band (chain is the truth; PSS is + // only the wake-up). + const settled = new Promise((resolve, reject) => { + ctx.pending.set(jobIdRouting, { resolve, reject }); + }); + + const slot: FailureSlot = { onChainJobId, deliveryDeadline }; + ctx.failureTimers.set(jobIdRouting, slot); + ctx.scheduleAckTimeout(jobIdRouting, slot); + + const envelope = await signEnvelope( + { + from: ctx.chain.address, + to: target.provider.owner, + type: 'job_notify', + body: { + jobId: jobIdRouting, + requestHash, + modelId: req.model, + maxPayment: rawWei.toString(), + deliveryDeadline, + clientPssPubKey: ctx.pssPubKeyX, + clientSwarmOverlay: ctx.selfOverlay, + }, + }, + (msg) => ctx.chain.wallet.signMessage({ account: ctx.chain.wallet.account!, message: msg }), + ); + try { + await ctx.pss.send({ + topic: providerTopic(target.provider.owner), + recipientOverlay: target.provider.swarmOverlay, + recipientPssKey: target.provider.pssPublicKey, + envelope, + }); + } catch (err) { + // Orphan cleanup: drop the pending state synchronously so the ACK timer + // is a no-op when it fires, and release the reservation. + ctx.pending.delete(jobIdRouting); + ctx.jobMeta.delete(jobIdRouting); + ctx.clearFailureTimers(jobIdRouting); + await releaseReservation(userId, created.id, withMarkupWei); + releasedReservation = true; + throw err; + } + + const response = await settled; + return { gatewayJobId: created.id, response }; + } catch (err) { + // On any failure before / during dispatch, release the reservation. Skip + // if the orphan-cleanup branch above already did so. + if (!releasedReservation && gatewayJobId) { + await releaseReservation(userId, gatewayJobId, withMarkupWei).catch((e) => + logger.warn({ err: e.message, gatewayJobId }, 'release on failure failed'), + ); + } else if (!releasedReservation) { + // No GatewayJob row created yet — manually decrement reserved. + await prisma.account + .update({ + where: { userId }, + data: { reservedXbzzWei: { decrement: withMarkupWei.toString() } }, + }) + .catch((e) => logger.warn({ err: e.message, userId }, 'reserved decrement on failure failed')); + } + throw err; + } +} diff --git a/src/gateway/routes.ts b/src/gateway/routes.ts new file mode 100644 index 0000000..32bb3c3 --- /dev/null +++ b/src/gateway/routes.ts @@ -0,0 +1,110 @@ +import Elysia, { t } from 'elysia'; +import { jwtGuard } from '../auth/jwt'; +import { prisma } from '../db'; +import { handleChat } from './handleChat'; +import { getGatewayContext } from './context'; +import { Semaphore } from '../queue/semaphore'; +import { env } from '../env'; +import { logger } from '../logger'; +import { InsufficientBalanceError } from '../accounts/ledger'; + +const semaphore = new Semaphore(env.MAX_CONCURRENT_JOBS); + +export const gatewayRoutes = new Elysia() + .use(jwtGuard) + .get('/v1/models', async () => { + const ctx = await getGatewayContext(); + const summaries = await ctx.discovery.list(); + const created = Math.floor(Date.now() / 1000); + return { + object: 'list', + data: summaries.map((m) => ({ + id: m.id, + object: 'model' as const, + created, + owned_by: 't4t', + })), + }; + }) + .post( + '/v1/chat/completions', + async ({ body, user, set }) => { + if (!semaphore.tryAcquire()) { + set.status = 503; + set.headers['Retry-After'] = '5'; + throw new Error('Server busy — too many concurrent jobs in flight'); + } + + try { + const ctx = await getGatewayContext(); + const { gatewayJobId, response } = await handleChat({ + ctx, + userId: user.userId, + req: body as any, + }); + return response; + } catch (err: any) { + if (err instanceof InsufficientBalanceError) { + set.status = 402; + throw err; + } + logger.error({ err: err.message, userId: user.userId }, 'chat completion failed'); + set.status = 500; + throw err; + } finally { + semaphore.release(); + } + }, + { + // Pass through the OpenAI-style payload; we don't enforce the schema + // strictly — any unknown fields just ride along to the provider. + body: t.Object( + { + model: t.String(), + messages: t.Array( + t.Object({ + role: t.Union([ + t.Literal('system'), + t.Literal('user'), + t.Literal('assistant'), + t.Literal('tool'), + ]), + content: t.String(), + }), + ), + temperature: t.Optional(t.Number()), + max_tokens: t.Optional(t.Number()), + stream: t.Optional(t.Boolean()), + }, + { additionalProperties: true }, + ), + }, + ) + .get('/v1/jobs', async ({ user, query }) => { + const limit = Math.min(Number(query.limit ?? 50), 200); + const jobs = await prisma.gatewayJob.findMany({ + where: { userId: user.userId }, + orderBy: { createdAt: 'desc' }, + take: limit, + select: { + id: true, + status: true, + modelId: true, + provider: true, + estimatedMaxXbzzWei: true, + actualXbzzWei: true, + promptTokens: true, + completionTokens: true, + createdAt: true, + ackedAt: true, + deliveredAt: true, + claimedAt: true, + errorMessage: true, + }, + }); + return jobs.map((j) => ({ + ...j, + estimatedMaxXbzzWei: j.estimatedMaxXbzzWei.toString(), + actualXbzzWei: j.actualXbzzWei?.toString() ?? null, + })); + }); diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..0a95680 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,12 @@ +import pino from 'pino'; +import { env } from './env'; + +export const logger = pino({ + level: env.LOG_LEVEL, + transport: + env.LOG_LEVEL === 'debug' || env.LOG_LEVEL === 'trace' + ? { target: 'pino-pretty', options: { colorize: true } } + : undefined, +}); + +export type Logger = typeof logger; diff --git a/src/pricing/estimate.ts b/src/pricing/estimate.ts new file mode 100644 index 0000000..7e5b0a0 --- /dev/null +++ b/src/pricing/estimate.ts @@ -0,0 +1,49 @@ +import type { ModelOffering } from '../t4t/lib/types'; +import { env } from '../env'; + +const BPS_DIVISOR = 10_000n; +const TOKENS_PER_MILLION = 1_000_000n; + +export interface ChatRequest { + model: string; + max_tokens?: number; + messages?: Array<{ content: string }>; +} + +/** + * Rough upper-bound xBZZ cost for a single chat completion. Mirrors the + * `maxPayment` semantics on JobEscrow.postJob. + * + * Returns `{ raw, withMarkup }` so the caller can: + * - post the *raw* amount on-chain as `maxPayment` (markup is operator + * revenue, not paid to providers) + * - reserve the *with-markup* amount off-chain + */ +export function estimateMaxXbzz( + req: ChatRequest, + offering: Pick, +): { rawWei: bigint; withMarkupWei: bigint } { + const maxInputTokens = estimateInputTokens(req); + const maxOutputTokens = BigInt(req.max_tokens ?? Number(offering.maxContextTokens)); + + const inputCost = (BigInt(maxInputTokens) * BigInt(offering.inputPricePerMillionTokens)) / TOKENS_PER_MILLION; + const outputCost = (maxOutputTokens * BigInt(offering.outputPricePerMillionTokens)) / TOKENS_PER_MILLION; + const rawWei = inputCost + outputCost; + const withMarkupWei = (rawWei * (BPS_DIVISOR + BigInt(env.MARKUP_BPS))) / BPS_DIVISOR; + return { rawWei, withMarkupWei }; +} + +/** + * Approximate input-token count from messages. Uses a 4-char-per-token + * heuristic — fine for an upper-bound estimate; the actual cost is recorded + * later from the JobClaimed event. + */ +function estimateInputTokens(req: ChatRequest): number { + const totalChars = (req.messages ?? []).reduce((acc, m) => acc + (m.content?.length ?? 0), 0); + return Math.ceil(totalChars / 4) + 16; // +16 for system / role overhead +} + +/** Apply the same markup function used at reservation time to a settled cost. */ +export function applyMarkup(rawWei: bigint): bigint { + return (rawWei * (BPS_DIVISOR + BigInt(env.MARKUP_BPS))) / BPS_DIVISOR; +} diff --git a/src/pricing/usdDisplay.ts b/src/pricing/usdDisplay.ts new file mode 100644 index 0000000..811ba79 --- /dev/null +++ b/src/pricing/usdDisplay.ts @@ -0,0 +1,21 @@ +// USD conversion helpers — DASHBOARD COSMETICS ONLY. The billing path never +// touches USD; see plan section "Decisions locked in". +import { getBzzUsd } from '../t4t/lib/bzz-price'; +import { logger } from '../logger'; + +const WEI_PER_XBZZ = 10n ** 18n; + +/** + * Returns a USD-cents equivalent of an xBZZ-wei amount, or `null` if the price + * oracle isn't reachable. + */ +export async function getUsdEquivalent(wei: bigint): Promise { + try { + const usdPerXbzz = await getBzzUsd(logger); + if (!usdPerXbzz || usdPerXbzz <= 0) return null; + const xbzz = Number(wei) / Number(WEI_PER_XBZZ); // safe loss for display + return Math.round(xbzz * usdPerXbzz * 100); + } catch { + return null; + } +} diff --git a/src/queue/connection.ts b/src/queue/connection.ts new file mode 100644 index 0000000..1324542 --- /dev/null +++ b/src/queue/connection.ts @@ -0,0 +1,8 @@ +import IORedis from 'ioredis'; +import { env } from '../env'; + +export const redisConnection = new IORedis(env.REDIS_PORT, env.REDIS_HOST, { + maxRetriesPerRequest: null, +}); + +export const queueConnection = { host: env.REDIS_HOST, port: env.REDIS_PORT }; diff --git a/src/queue/lock.ts b/src/queue/lock.ts new file mode 100644 index 0000000..582e99f --- /dev/null +++ b/src/queue/lock.ts @@ -0,0 +1,37 @@ +import { randomUUID } from 'crypto'; +import { redisConnection } from './connection'; + +const DEFAULT_TTL = 30_000; +const RETRY_INTERVAL = 50; + +/** + * Distributed Redis lock using SET NX EX. Serializes critical sections across + * api + worker processes — used for the operator wallet nonce + * (`chain:operator`), claim-watcher leader election (`watcher:claim`), etc. + */ +export async function withLock( + key: string, + fn: () => Promise, + ttl = DEFAULT_TTL, +): Promise { + const lockKey = `lock:${key}`; + const lockValue = randomUUID(); + const ttlSeconds = Math.ceil(ttl / 1000); + + while (true) { + const acquired = await redisConnection.set(lockKey, lockValue, 'EX', ttlSeconds, 'NX'); + if (acquired === 'OK') break; + await new Promise((r) => setTimeout(r, RETRY_INTERVAL)); + } + + try { + return await fn(); + } finally { + await redisConnection.eval( + `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`, + 1, + lockKey, + lockValue, + ); + } +} diff --git a/src/queue/processor.ts b/src/queue/processor.ts new file mode 100644 index 0000000..03d1191 --- /dev/null +++ b/src/queue/processor.ts @@ -0,0 +1,92 @@ +import type { Job } from 'bullmq'; +import { isDeepStrictEqual } from 'node:util'; +import { logger } from '../logger'; + +/** + * Base processor for the CUD-and-enqueue pattern. Subclasses override + * `onCreated` / `getFieldHandlers` / `onDeleted`. + * + * Ported from cipherdolls/core/src/queue/processor.ts. `publishProcessEvent` + * is dropped — MQTT broker arrives in Phase 3. + */ +export abstract class BaseProcessor> { + constructor( + protected readonly entityName: string, + protected readonly scalarFields: readonly string[], + ) {} + + async process(job: Job): Promise { + switch (job.name) { + case 'created': + return this.handleCreated(job); + case 'updated': + return this.handleUpdated(job); + case 'deleted': + return this.handleDeleted(job); + default: + logger.warn({ entity: this.entityName, jobName: job.name }, 'unknown job name'); + } + } + + protected async handleCreated(job: Job): Promise { + const entity = job.data[this.entityName] as T; + logger.debug({ entity: this.entityName, id: entity?.id }, 'created'); + try { + await this.onCreated(job, entity); + } catch (err: any) { + logger.error({ entity: this.entityName, id: entity?.id, err: err.message }, 'onCreated failed'); + throw err; + } + } + + protected async handleUpdated(job: Job): Promise { + const entity = job.data[this.entityName] as T; + const original = job.data.original as T | undefined; + if (!original) { + logger.warn({ entity: this.entityName, id: entity?.id }, 'updated without original snapshot'); + return; + } + const updatedFields = this.scalarFields.filter( + (key) => !isDeepStrictEqual((original as any)[key], (entity as any)[key]), + ); + if (updatedFields.length === 0) return; + + logger.debug( + { entity: this.entityName, id: entity?.id, fields: updatedFields }, + 'updated', + ); + try { + const handlers = this.getFieldHandlers(job, entity); + for (const field of updatedFields) { + const handler = handlers[field]; + if (handler) await handler(); + } + await this.onUpdated(job, entity, original, updatedFields); + } catch (err: any) { + logger.error( + { entity: this.entityName, id: entity?.id, err: err.message }, + 'onUpdated failed', + ); + throw err; + } + } + + protected async handleDeleted(job: Job): Promise { + const entity = job.data[this.entityName] as T; + logger.debug({ entity: this.entityName, id: entity?.id }, 'deleted'); + await this.onDeleted(job, entity); + } + + protected async onCreated(_job: Job, _entity: T): Promise {} + protected async onUpdated( + _job: Job, + _entity: T, + _original: T, + _updatedFields: string[], + ): Promise {} + protected async onDeleted(_job: Job, _entity: T): Promise {} + + protected getFieldHandlers(_job: Job, _entity: T): Record Promise> { + return {}; + } +} diff --git a/src/queue/registry.ts b/src/queue/registry.ts new file mode 100644 index 0000000..30112a8 --- /dev/null +++ b/src/queue/registry.ts @@ -0,0 +1,43 @@ +import { Queue, Worker, type Job, type Processor } from 'bullmq'; +import { queueConnection } from './connection'; +import { logger } from '../logger'; + +const defaultOpts = { concurrency: 1 }; + +const queues = new Map(); +const workers = new Map(); + +export function getQueue(name: string): Queue { + if (!queues.has(name)) { + queues.set(name, new Queue(name, { connection: queueConnection })); + } + return queues.get(name)! as Queue; +} + +export function registerWorker( + name: string, + handler: Processor, + opts?: { concurrency?: number }, +): Worker { + const worker = new Worker(name, handler, { + connection: queueConnection, + concurrency: opts?.concurrency ?? defaultOpts.concurrency, + autorun: true, + }); + + worker.on('failed', (job: Job | undefined, err: Error) => { + logger.error({ queue: name, jobId: job?.id, jobName: job?.name, err: err.message }, 'job failed'); + }); + + worker.on('completed', (job: Job) => { + logger.debug({ queue: name, jobId: job.id, jobName: job.name }, 'job completed'); + }); + + workers.set(name, worker); + return worker; +} + +export async function closeAll() { + for (const w of workers.values()) await w.close(); + for (const q of queues.values()) await q.close(); +} diff --git a/src/queue/semaphore.ts b/src/queue/semaphore.ts new file mode 100644 index 0000000..5da71ff --- /dev/null +++ b/src/queue/semaphore.ts @@ -0,0 +1,47 @@ +/** + * Simple in-process counting semaphore for the chat-completion concurrency cap. + * Bounds Bee / Gnosis RPC / Postgres pressure when many concurrent users hit + * `/v1/chat/completions`. + * + * Not Redis-distributed — each api process keeps its own counter. With Phase 1 + * being a single api process this is sufficient; Phase 3 (horizontal scale) + * will replace with a Redis token bucket. + */ +export class Semaphore { + private available: number; + private waiters: Array<() => void> = []; + + constructor(private readonly capacity: number) { + this.available = capacity; + } + + async acquire(): Promise { + if (this.available > 0) { + this.available -= 1; + return; + } + await new Promise((resolve) => this.waiters.push(resolve)); + } + + /** Non-blocking variant: returns false if capacity is exhausted. */ + tryAcquire(): boolean { + if (this.available > 0) { + this.available -= 1; + return true; + } + return false; + } + + release(): void { + const next = this.waiters.shift(); + if (next) { + next(); + } else { + this.available = Math.min(this.available + 1, this.capacity); + } + } + + get inFlight(): number { + return this.capacity - this.available; + } +} diff --git a/src/queue/service.ts b/src/queue/service.ts new file mode 100644 index 0000000..f1ec634 --- /dev/null +++ b/src/queue/service.ts @@ -0,0 +1,58 @@ +import { getQueue } from './registry'; + +/** Recursively convert BigInt → string so BullMQ's JSON.stringify can serialize. */ +function serializeBigInts(obj: any): any { + if (obj === null || obj === undefined) return obj; + if (typeof obj === 'bigint') return obj.toString(); + if (Array.isArray(obj)) return obj.map(serializeBigInts); + if (typeof obj === 'object' && !(obj instanceof Date)) { + const result: any = {}; + for (const [k, v] of Object.entries(obj)) result[k] = serializeBigInts(v); + return result; + } + return obj; +} + +/** + * Wraps a Prisma model delegate so CUD calls persist AND enqueue a BullMQ job. + * Reads stay on `prisma.x` directly. + * + * Ported from cipherdolls/core/src/queue/service.ts. + */ +export function createCudService( + queueName: string, + prismaDelegate: { + create: (args: any) => Promise; + update: (args: any) => Promise; + delete: (args: any) => Promise; + findUnique: (args: any) => Promise; + }, +) { + const queue = getQueue(queueName); + + return { + async create(args: any): Promise { + const entity = await prismaDelegate.create(args); + await queue.add('created', { [queueName]: serializeBigInts(entity) }); + return entity; + }, + + async update(args: { where: any; data: any; include?: any }, original?: T): Promise { + if (!original) { + original = (await prismaDelegate.findUnique({ where: args.where })) as T; + } + const entity = await prismaDelegate.update(args); + await queue.add('updated', { + [queueName]: serializeBigInts(entity), + original: serializeBigInts(original), + }); + return entity; + }, + + async delete(args: { where: any }): Promise { + const entity = await prismaDelegate.findUnique({ where: args.where }); + await queue.add('deleted', { [queueName]: serializeBigInts(entity) }); + return prismaDelegate.delete(args); + }, + }; +} diff --git a/src/queue/startup.ts b/src/queue/startup.ts new file mode 100644 index 0000000..39bbe2f --- /dev/null +++ b/src/queue/startup.ts @@ -0,0 +1,27 @@ +import { closeAll, registerWorker } from './registry'; +import { logger } from '../logger'; + +// Per-entity processors registered as they're built (Phase 1 onwards). +// Import lazily so worker.ts can call startWorkers() without pulling in api +// surface code. + +export function startWorkers() { + logger.info('starting BullMQ workers…'); + + // Domain processors — added module-by-module as we build them. + // Example wiring (uncomment once the module ships): + // import { usersProcessor } from '../users/processor'; + // registerWorker('user', (job) => usersProcessor.process(job)); + + const { usersProcessor } = require('../users/processor'); + registerWorker('user', (job: any) => usersProcessor.process(job)); + + const { accountsProcessor } = require('../accounts/processor'); + registerWorker('account', (job: any) => accountsProcessor.process(job)); + + logger.info('BullMQ workers registered'); +} + +export async function stopWorkers() { + await closeAll(); +} diff --git a/src/t4t/VENDORED.md b/src/t4t/VENDORED.md new file mode 100644 index 0000000..8d23ad8 --- /dev/null +++ b/src/t4t/VENDORED.md @@ -0,0 +1,64 @@ +# Vendored from t4t-container + +These files are copied verbatim from +[`token-for-token/t4t`](https://github.com/token-for-token/t4t) at commit +**`f525b8bf93349dcaef2e234a6ad9e49871dcf12d`** (path: +`container/src/lib/*.ts`, `container/src/modes/gateway/{models,selector}.ts`). + +The hosted gateway is a hosted multi-tenant frontend that uses the same chain ++ Swarm + PSS protocol. We reuse the engine code as a library; rewriting it +would invite drift, and a separate npm package is premature. + +## Files vendored + +``` +src/t4t/lib/ +├── abi.ts # ProviderRegistry + JobEscrow + ERC20 ABIs +├── bzz-price.ts # CoinGecko USD price (cosmetic display only) +├── chain.ts # viem clients + read/write wrappers (postJob, claimJob, …) +├── crypto.ts # ECIES (payload encryption) +├── envelope.ts # PSS envelope sign/verify, topic derivation, dedup +├── keys.ts # PSS keypair generation (secp256k1) +├── logger.ts # ⚠️ LOCAL EDIT — re-exports project logger (see below) +├── stamps.ts # operator-managed postage batch helpers +├── swarm.ts # bee-js wrapper (chunks + PssTransport) +├── types.ts # shared chain types +└── wallet.ts # BIP39 / private-key helpers +src/t4t/modes/gateway/ +├── models.ts # ModelDiscovery (LRU-cached registry scan) +└── selector.ts # selectProvider() — stateless, filters by model+price+rep +``` + +The `modes/gateway/` depth mirrors the upstream `container/src/modes/gateway/` +layout so the internal `import {…} from '../../lib/chain'` paths keep working +verbatim. **Don't flatten this** — it's the difference between zero edits to +vendored files and a maintenance burden on every sync. + +## Files deliberately NOT vendored + +| Source path | Reason | +|---|---| +| `lib/onboarding.ts`, `modes/gateway/admin.ts`, `modes/gateway/server.ts` | Replaced by Elysia signup + dashboard + routes. | +| `lib/jobs-db.ts` | Replaced by Prisma `GatewayJob` + `userId` tenant attribution. | +| `lib/job-index.ts` | Replaced by Postgres-backed `src/chain/jobIndex.ts`. | +| `lib/config.ts` | Replaced by `src/env.ts` + per-tenant rows. | +| `lib/admin-html.ts`, `lib/admin-stamps.ts` | Single-operator admin UI. | +| `lib/inference.ts` | Provider-side (not used by gateway). | +| `modes/gateway/index.ts` | `handleChat` is refactored into `src/gateway/handleChat.ts`. | + +## Drift policy + +- **No edits** to vendored files. The one explicit exception is `lib/logger.ts` + (re-exports the project logger so vendored code shares the same pino + instance). Any further required change must be filed upstream first. +- Re-sync upstream every two weeks: + + ```bash + diff -ru ../t4t/container/src/lib/ src/t4t/lib/ + diff -ru ../t4t/container/src/modes/gateway/{models,selector}.ts \ + src/t4t/gateway/{models,selector}.ts + ``` + + Bump the SHA at the top of this file when re-syncing. +- If upstream renames or restructures these files, mirror the change here in the + same commit that updates the SHA. diff --git a/src/t4t/lib/abi.ts b/src/t4t/lib/abi.ts new file mode 100644 index 0000000..fb9203a --- /dev/null +++ b/src/t4t/lib/abi.ts @@ -0,0 +1,238 @@ +/** + * Hand-written ABIs for the three T4T contracts so the container builds + * without a Forge artifact pipeline. Regenerate from `contracts/out/` if + * the Solidity sources drift. + */ + +export const providerRegistryAbi = [ + { + type: 'function', + name: 'register', + stateMutability: 'nonpayable', + inputs: [ + {name: 'pssPublicKey', type: 'bytes32'}, + {name: 'swarmOverlay', type: 'bytes32'}, + {name: 'metadataURI', type: 'string'}, + {name: 'initialStake', type: 'uint128'}, + ], + outputs: [], + }, + { + type: 'function', + name: 'updateOfferings', + stateMutability: 'nonpayable', + inputs: [ + { + name: 'offerings', + type: 'tuple[]', + components: [ + {name: 'modelId', type: 'string'}, + {name: 'inputPricePerMillionTokens', type: 'uint128'}, + {name: 'outputPricePerMillionTokens', type: 'uint128'}, + {name: 'maxContextTokens', type: 'uint128'}, + {name: 'maxLatencySeconds', type: 'uint64'}, + ], + }, + ], + outputs: [], + }, + {type: 'function', name: 'heartbeat', stateMutability: 'nonpayable', inputs: [], outputs: []}, + { + type: 'function', + name: 'MIN_STAKE', + stateMutability: 'view', + inputs: [], + outputs: [{type: 'uint128'}], + }, + {type: 'function', name: 'deactivate', stateMutability: 'nonpayable', inputs: [], outputs: []}, + {type: 'function', name: 'withdrawStake', stateMutability: 'nonpayable', inputs: [], outputs: []}, + { + type: 'function', + name: 'isLive', + stateMutability: 'view', + inputs: [{name: 'owner', type: 'address'}], + outputs: [{type: 'bool'}], + }, + { + type: 'function', + name: 'getProvider', + stateMutability: 'view', + inputs: [{name: 'owner', type: 'address'}], + outputs: [ + { + type: 'tuple', + components: [ + {name: 'owner', type: 'address'}, + {name: 'pssPublicKey', type: 'bytes32'}, + {name: 'swarmOverlay', type: 'bytes32'}, + {name: 'metadataURI', type: 'string'}, + {name: 'stake', type: 'uint128'}, + {name: 'lastHeartbeat', type: 'uint64'}, + {name: 'totalJobs', type: 'uint32'}, + {name: 'successfulJobs', type: 'uint32'}, + {name: 'active', type: 'bool'}, + ], + }, + ], + }, + { + type: 'function', + name: 'getOfferings', + stateMutability: 'view', + inputs: [{name: 'owner', type: 'address'}], + outputs: [ + { + type: 'tuple[]', + components: [ + {name: 'modelId', type: 'string'}, + {name: 'inputPricePerMillionTokens', type: 'uint128'}, + {name: 'outputPricePerMillionTokens', type: 'uint128'}, + {name: 'maxContextTokens', type: 'uint128'}, + {name: 'maxLatencySeconds', type: 'uint64'}, + ], + }, + ], + }, + { + type: 'function', + name: 'listProviders', + stateMutability: 'view', + inputs: [ + {name: 'cursor', type: 'uint256'}, + {name: 'limit', type: 'uint256'}, + ], + outputs: [ + { + type: 'tuple[]', + components: [ + {name: 'owner', type: 'address'}, + {name: 'pssPublicKey', type: 'bytes32'}, + {name: 'swarmOverlay', type: 'bytes32'}, + {name: 'metadataURI', type: 'string'}, + {name: 'stake', type: 'uint128'}, + {name: 'lastHeartbeat', type: 'uint64'}, + {name: 'totalJobs', type: 'uint32'}, + {name: 'successfulJobs', type: 'uint32'}, + {name: 'active', type: 'bool'}, + ], + }, + {name: 'nextCursor', type: 'uint256'}, + ], + }, +] as const + +export const jobEscrowAbi = [ + { + type: 'function', + name: 'postJob', + stateMutability: 'nonpayable', + inputs: [ + {name: 'provider', type: 'address'}, + {name: 'requestHash', type: 'bytes32'}, + {name: 'modelId', type: 'string'}, + {name: 'maxPayment', type: 'uint128'}, + {name: 'deliveryDeadline', type: 'uint64'}, + ], + outputs: [{name: 'jobId', type: 'bytes32'}], + }, + { + type: 'function', + name: 'ackJob', + stateMutability: 'nonpayable', + inputs: [{name: 'jobId', type: 'bytes32'}], + outputs: [], + }, + { + type: 'function', + name: 'claimJob', + stateMutability: 'nonpayable', + inputs: [ + {name: 'jobId', type: 'bytes32'}, + {name: 'responseHash', type: 'bytes32'}, + {name: 'actualPayment', type: 'uint128'}, + {name: 'clientSig', type: 'bytes'}, + ], + outputs: [], + }, + { + type: 'function', + name: 'cancelJob', + stateMutability: 'nonpayable', + inputs: [{name: 'jobId', type: 'bytes32'}], + outputs: [], + }, + { + type: 'function', + name: 'timeoutJob', + stateMutability: 'nonpayable', + inputs: [{name: 'jobId', type: 'bytes32'}], + outputs: [], + }, + { + type: 'function', + name: 'jobs', + stateMutability: 'view', + inputs: [{name: 'jobId', type: 'bytes32'}], + outputs: [ + {name: 'client', type: 'address'}, + {name: 'provider', type: 'address'}, + {name: 'requestHash', type: 'bytes32'}, + {name: 'responseHash', type: 'bytes32'}, + {name: 'modelId', type: 'string'}, + {name: 'maxPayment', type: 'uint128'}, + {name: 'postedAt', type: 'uint64'}, + {name: 'ackedAt', type: 'uint64'}, + {name: 'ackDeadline', type: 'uint64'}, + {name: 'deliveryDeadline', type: 'uint64'}, + {name: 'status', type: 'uint8'}, + ], + }, + { + type: 'event', + name: 'JobPosted', + inputs: [ + {name: 'jobId', type: 'bytes32', indexed: true}, + {name: 'client', type: 'address', indexed: true}, + {name: 'provider', type: 'address', indexed: true}, + ], + }, + { + type: 'event', + name: 'JobClaimed', + inputs: [ + {name: 'jobId', type: 'bytes32', indexed: true}, + {name: 'responseHash', type: 'bytes32'}, + {name: 'paid', type: 'uint128'}, + ], + }, +] as const + +export const erc20Abi = [ + { + type: 'function', + name: 'approve', + stateMutability: 'nonpayable', + inputs: [ + {name: 'spender', type: 'address'}, + {name: 'amount', type: 'uint256'}, + ], + outputs: [{type: 'bool'}], + }, + { + type: 'function', + name: 'allowance', + stateMutability: 'view', + inputs: [ + {name: 'owner', type: 'address'}, + {name: 'spender', type: 'address'}, + ], + outputs: [{type: 'uint256'}], + }, + { + type: 'function', + name: 'balanceOf', + stateMutability: 'view', + inputs: [{name: 'owner', type: 'address'}], + outputs: [{type: 'uint256'}], + }, +] as const diff --git a/src/t4t/lib/bzz-price.ts b/src/t4t/lib/bzz-price.ts new file mode 100644 index 0000000..ac3a242 --- /dev/null +++ b/src/t4t/lib/bzz-price.ts @@ -0,0 +1,47 @@ +/** + * BZZ → USD price lookup via CoinGecko's free tier. Cached 5 min so the + * admin UI can show a USD-equivalent alongside PLUR/BZZ without hammering + * the API. Failures fall back to the last good value (or null if we've + * never had one) — the calling page just hides the USD column. + */ +import type {Logger} from './logger' + +const COINGECKO_URL = 'https://api.coingecko.com/api/v3/simple/price?ids=swarm-bzz&vs_currencies=usd' +const CACHE_TTL_MS = 5 * 60 * 1000 + +interface Cached { + usd: number + fetchedAt: number +} + +let cached: Cached | null = null +let inflight: Promise | null = null + +export async function getBzzUsd(logger?: Logger): Promise { + const now = Date.now() + if (cached && now - cached.fetchedAt < CACHE_TTL_MS) return cached.usd + if (inflight) return inflight + inflight = (async () => { + try { + const r = await fetch(COINGECKO_URL, {headers: {accept: 'application/json'}}) + if (!r.ok) { + logger?.warn({status: r.status}, 'CoinGecko BZZ price fetch returned non-2xx') + return cached?.usd ?? null + } + const data = (await r.json()) as Record + const usd = data['swarm-bzz']?.usd + if (typeof usd !== 'number' || !isFinite(usd) || usd <= 0) { + logger?.warn({data}, 'CoinGecko BZZ price missing or invalid') + return cached?.usd ?? null + } + cached = {usd, fetchedAt: now} + return usd + } catch (err) { + logger?.warn({err}, 'CoinGecko BZZ price fetch failed') + return cached?.usd ?? null + } finally { + inflight = null + } + })() + return inflight +} diff --git a/src/t4t/lib/chain.ts b/src/t4t/lib/chain.ts new file mode 100644 index 0000000..ee239fe --- /dev/null +++ b/src/t4t/lib/chain.ts @@ -0,0 +1,353 @@ +import { + createPublicClient, + createWalletClient, + http, + parseEventLogs, + type Address, + type Hex, + type PublicClient, + type WalletClient, +} from 'viem' +import {privateKeyToAccount} from 'viem/accounts' +import {gnosis, gnosisChiado} from 'viem/chains' +import {erc20Abi, jobEscrowAbi, providerRegistryAbi} from './abi' +import type {ModelOffering, ProviderRow} from './types' + +/** Mirrors `JobEscrow.ACK_WINDOW` (spec §3). Used by the client to schedule + * the no-ack cancel deadline locally. Keep in sync with the contract. */ +export const ACK_WINDOW_SECONDS = 30 + +export interface TxEvent { + kind: string + hash: Hex + toAddress: Address + note?: string | null +} + +export interface ChainClient { + pub: PublicClient + wallet: WalletClient + address: Address + registry: Address + escrow: Address + xbzz: Address + /** Optional sink for every write tx after it's been submitted. Wired in by + * the provider/client startup to persist to `transactions` in jobs.db. */ + onTx?: (e: TxEvent) => void +} + +export interface ChainOpts { + rpcUrl: string + privateKey: Hex + registry: Address + escrow: Address + xbzz: Address + chainId?: number + onTx?: (e: TxEvent) => void +} + +export function makeChain(opts: ChainOpts): ChainClient { + const account = privateKeyToAccount(opts.privateKey) + const chain = opts.chainId === 10200 ? gnosisChiado : gnosis + const transport = http(opts.rpcUrl) + return { + pub: createPublicClient({chain, transport}), + wallet: createWalletClient({chain, transport, account}), + address: account.address, + registry: opts.registry, + escrow: opts.escrow, + xbzz: opts.xbzz, + onTx: opts.onTx, + } +} + +/** Notify the txLog sink. Swallow any error in the callback so a faulty DB + * write never takes down a chain operation. */ +function emit(c: ChainClient, e: TxEvent): void { + try { + c.onTx?.(e) + } catch { + // intentionally ignored + } +} + +// ---------- ProviderRegistry ---------- + +export async function registerProvider( + c: ChainClient, + args: { + pssPublicKey: Hex + swarmOverlay: Hex + metadataURI: string + initialStake: bigint + }, +): Promise { + const hash = await c.wallet.writeContract({ + chain: c.wallet.chain!, + account: c.wallet.account!, + address: c.registry, + abi: providerRegistryAbi, + functionName: 'register', + args: [args.pssPublicKey as `0x${string}`, args.swarmOverlay as `0x${string}`, args.metadataURI, args.initialStake], + }) + emit(c, {kind: 'registerProvider', hash, toAddress: c.registry, note: `stake=${args.initialStake.toString()}`}) + return hash +} + +export async function updateOfferings(c: ChainClient, offerings: ModelOffering[]): Promise { + const hash = await c.wallet.writeContract({ + chain: c.wallet.chain!, + account: c.wallet.account!, + address: c.registry, + abi: providerRegistryAbi, + functionName: 'updateOfferings', + args: [offerings], + }) + emit(c, {kind: 'updateOfferings', hash, toAddress: c.registry, note: `models=${offerings.length}`}) + return hash +} + +export async function sendHeartbeat(c: ChainClient): Promise { + const hash = await c.wallet.writeContract({ + chain: c.wallet.chain!, + account: c.wallet.account!, + address: c.registry, + abi: providerRegistryAbi, + functionName: 'heartbeat', + args: [], + }) + emit(c, {kind: 'heartbeat', hash, toAddress: c.registry}) + return hash +} + +export async function deactivateProvider(c: ChainClient): Promise { + const hash = await c.wallet.writeContract({ + chain: c.wallet.chain!, + account: c.wallet.account!, + address: c.registry, + abi: providerRegistryAbi, + functionName: 'deactivate', + args: [], + }) + emit(c, {kind: 'deactivateProvider', hash, toAddress: c.registry}) + return hash +} + +export async function withdrawStake(c: ChainClient): Promise { + const hash = await c.wallet.writeContract({ + chain: c.wallet.chain!, + account: c.wallet.account!, + address: c.registry, + abi: providerRegistryAbi, + functionName: 'withdrawStake', + args: [], + }) + emit(c, {kind: 'withdrawStake', hash, toAddress: c.registry}) + return hash +} + +export async function readMinStake(c: ChainClient): Promise { + return (await c.pub.readContract({ + address: c.registry, + abi: providerRegistryAbi, + functionName: 'MIN_STAKE', + args: [], + })) as bigint +} + +export async function readXbzzBalance(c: ChainClient, owner: Address): Promise { + return (await c.pub.readContract({ + address: c.xbzz, + abi: [ + { + type: 'function', + name: 'balanceOf', + stateMutability: 'view', + inputs: [{name: 'o', type: 'address'}], + outputs: [{type: 'uint256'}], + }, + ], + functionName: 'balanceOf', + args: [owner], + })) as bigint +} + +export async function getProvider(c: ChainClient, owner: Address): Promise { + const raw = await c.pub.readContract({ + address: c.registry, + abi: providerRegistryAbi, + functionName: 'getProvider', + args: [owner], + }) + return raw as unknown as ProviderRow +} + +export async function getOfferings(c: ChainClient, owner: Address): Promise { + const raw = await c.pub.readContract({ + address: c.registry, + abi: providerRegistryAbi, + functionName: 'getOfferings', + args: [owner], + }) + return raw as unknown as ModelOffering[] +} + +export async function listProviders( + c: ChainClient, + cursor = 0n, + limit = 50n, +): Promise<{page: ProviderRow[]; nextCursor: bigint}> { + const [page, nextCursor] = (await c.pub.readContract({ + address: c.registry, + abi: providerRegistryAbi, + functionName: 'listProviders', + args: [cursor, limit], + })) as unknown as [ProviderRow[], bigint] + return {page, nextCursor} +} + +// ---------- JobEscrow ---------- + +export async function postJob( + c: ChainClient, + args: { + provider: Address + requestHash: Hex + modelId: string + maxPayment: bigint + deliveryDeadline: number + }, +): Promise<{txHash: Hex; jobId: Hex}> { + const txHash = await c.wallet.writeContract({ + chain: c.wallet.chain!, + account: c.wallet.account!, + address: c.escrow, + abi: jobEscrowAbi, + functionName: 'postJob', + args: [args.provider, args.requestHash, args.modelId, args.maxPayment, BigInt(args.deliveryDeadline)], + }) + const receipt = await c.pub.waitForTransactionReceipt({hash: txHash}) + const events = parseEventLogs({abi: jobEscrowAbi, eventName: 'JobPosted', logs: receipt.logs}) + const ev = events[0] + if (!ev) throw new Error('postJob: JobPosted event missing from receipt') + const jobId = ev.args.jobId as Hex + emit(c, {kind: 'postJob', hash: txHash, toAddress: c.escrow, note: `model=${args.modelId} jobId=${jobId}`}) + return {txHash, jobId} +} + +export async function ackJob(c: ChainClient, jobId: Hex): Promise { + const hash = await c.wallet.writeContract({ + chain: c.wallet.chain!, + account: c.wallet.account!, + address: c.escrow, + abi: jobEscrowAbi, + functionName: 'ackJob', + args: [jobId], + }) + emit(c, {kind: 'ackJob', hash, toAddress: c.escrow, note: `jobId=${jobId}`}) + return hash +} + +export async function readJob( + c: ChainClient, + jobId: Hex, +): Promise<{ + client: Address + provider: Address + requestHash: Hex + responseHash: Hex + modelId: string + maxPayment: bigint + postedAt: bigint + ackedAt: bigint + ackDeadline: bigint + deliveryDeadline: bigint + status: number +}> { + const raw = (await c.pub.readContract({ + address: c.escrow, + abi: jobEscrowAbi, + functionName: 'jobs', + args: [jobId], + })) as readonly [Address, Address, Hex, Hex, string, bigint, bigint, bigint, bigint, bigint, number] + return { + client: raw[0], + provider: raw[1], + requestHash: raw[2], + responseHash: raw[3], + modelId: raw[4], + maxPayment: raw[5], + postedAt: raw[6], + ackedAt: raw[7], + ackDeadline: raw[8], + deliveryDeadline: raw[9], + status: raw[10], + } +} + +export async function cancelJob(c: ChainClient, jobId: Hex): Promise { + const hash = await c.wallet.writeContract({ + chain: c.wallet.chain!, + account: c.wallet.account!, + address: c.escrow, + abi: jobEscrowAbi, + functionName: 'cancelJob', + args: [jobId], + }) + emit(c, {kind: 'cancelJob', hash, toAddress: c.escrow, note: `jobId=${jobId}`}) + return hash +} + +export async function timeoutJob(c: ChainClient, jobId: Hex): Promise { + const hash = await c.wallet.writeContract({ + chain: c.wallet.chain!, + account: c.wallet.account!, + address: c.escrow, + abi: jobEscrowAbi, + functionName: 'timeoutJob', + args: [jobId], + }) + emit(c, {kind: 'timeoutJob', hash, toAddress: c.escrow, note: `jobId=${jobId}`}) + return hash +} + +export async function claimJob( + c: ChainClient, + args: {jobId: Hex; responseHash: Hex; actualPayment: bigint; clientSig?: Hex}, +): Promise { + const hash = await c.wallet.writeContract({ + chain: c.wallet.chain!, + account: c.wallet.account!, + address: c.escrow, + abi: jobEscrowAbi, + functionName: 'claimJob', + args: [args.jobId, args.responseHash, args.actualPayment, args.clientSig ?? '0x'], + }) + emit(c, {kind: 'claimJob', hash, toAddress: c.escrow, note: `jobId=${args.jobId} amount=${args.actualPayment.toString()}`}) + return hash +} + +// ---------- xBZZ ---------- + +export async function ensureAllowance( + c: ChainClient, + spender: Address, + needed: bigint, +): Promise { + const current = (await c.pub.readContract({ + address: c.xbzz, + abi: erc20Abi, + functionName: 'allowance', + args: [c.address, spender], + })) as bigint + if (current >= needed) return + const hash = await c.wallet.writeContract({ + chain: c.wallet.chain!, + account: c.wallet.account!, + address: c.xbzz, + abi: erc20Abi, + functionName: 'approve', + args: [spender, 2n ** 256n - 1n], + }) + emit(c, {kind: 'approve', hash, toAddress: c.xbzz, note: `spender=${spender} max`}) +} diff --git a/src/t4t/lib/crypto.ts b/src/t4t/lib/crypto.ts new file mode 100644 index 0000000..7fc41be --- /dev/null +++ b/src/t4t/lib/crypto.ts @@ -0,0 +1,89 @@ +/** + * ECIES payload encryption for Swarm chunks (spec §6). + * + * Hybrid scheme: secp256k1 ECDH → HKDF-SHA256 → AES-256-GCM. + * + * ciphertext = ephPubCompressed(33) || iv(12) || aesGcm(plaintext) + * + * The recipient's on-chain `pssPublicKey` is a 32-byte X coordinate (Y-even + * by convention; see lib/keys.ts), so we prepend 0x02 to reconstruct the + * compressed point. The ephemeral pub key is emitted compressed (33 bytes) + * since both Y parities can occur for ephemerals. + */ +import {gcm} from '@noble/ciphers/aes' +import {secp256k1} from '@noble/curves/secp256k1' +import {hkdf} from '@noble/hashes/hkdf' +import {sha256} from '@noble/hashes/sha2' +import {hexToBytes, randomBytes} from '@noble/hashes/utils' +import {pssPubKeyXToCompressed} from './keys' +import type {Hex} from './types' + +const EPH_PUB_LEN = 33 +const IV_LEN = 12 +const AES_KEY_LEN = 32 +const HKDF_INFO = new TextEncoder().encode('t4t-ecies-v1') + +export interface PayloadCipher { + encrypt(plaintext: Uint8Array, recipientPubKeyX: Hex): Promise + decrypt(ciphertext: Uint8Array): Promise +} + +export class EciesCipher implements PayloadCipher { + private readonly selfPriv: Uint8Array + + constructor(selfPssPrivateKey: Hex) { + const priv = hexToBytes(selfPssPrivateKey.replace(/^0x/, '')) + if (priv.length !== 32) throw new Error(`PSS private key must be 32 bytes, got ${priv.length}`) + if (!secp256k1.utils.isValidPrivateKey(priv)) throw new Error('invalid secp256k1 private key') + this.selfPriv = priv + } + + async encrypt(plaintext: Uint8Array, recipientPubKeyX: Hex): Promise { + const recipientPub = pssPubKeyXToCompressed(recipientPubKeyX) + const ephPriv = secp256k1.utils.randomPrivateKey() + const ephPub = secp256k1.getPublicKey(ephPriv, true) // 33 bytes + const shared = secp256k1.getSharedSecret(ephPriv, recipientPub, true) // [parity || X] + const key = hkdf(sha256, shared.subarray(1), undefined, HKDF_INFO, AES_KEY_LEN) + const iv = randomBytes(IV_LEN) + const ct = gcm(key, iv).encrypt(plaintext) + const out = new Uint8Array(EPH_PUB_LEN + IV_LEN + ct.length) + out.set(ephPub, 0) + out.set(iv, EPH_PUB_LEN) + out.set(ct, EPH_PUB_LEN + IV_LEN) + return out + } + + async decrypt(ciphertext: Uint8Array): Promise { + if (ciphertext.length < EPH_PUB_LEN + IV_LEN + 16) { + throw new Error('ciphertext too short for ECIES envelope') + } + const ephPub = ciphertext.subarray(0, EPH_PUB_LEN) + const iv = ciphertext.subarray(EPH_PUB_LEN, EPH_PUB_LEN + IV_LEN) + const ct = ciphertext.subarray(EPH_PUB_LEN + IV_LEN) + const shared = secp256k1.getSharedSecret(this.selfPriv, ephPub, true) + const key = hkdf(sha256, shared.subarray(1), undefined, HKDF_INFO, AES_KEY_LEN) + return gcm(key, iv).decrypt(ct) + } +} + +/** + * No-op cipher kept around for tests where you don't want to deal with key + * material. Production code paths construct EciesCipher. + */ +export class PassthroughCipher implements PayloadCipher { + async encrypt(plaintext: Uint8Array): Promise { + return plaintext + } + async decrypt(ciphertext: Uint8Array): Promise { + return ciphertext + } +} + +export function jsonEncrypt(cipher: PayloadCipher, recipient: Hex, value: unknown): Promise { + return cipher.encrypt(new TextEncoder().encode(JSON.stringify(value)), recipient) +} + +export async function jsonDecrypt(cipher: PayloadCipher, ct: Uint8Array): Promise { + const pt = await cipher.decrypt(ct) + return JSON.parse(new TextDecoder().decode(pt)) as T +} diff --git a/src/t4t/lib/envelope.ts b/src/t4t/lib/envelope.ts new file mode 100644 index 0000000..d5c5bb6 --- /dev/null +++ b/src/t4t/lib/envelope.ts @@ -0,0 +1,120 @@ +import {isAddressEqual, recoverMessageAddress, toHex} from 'viem' +import { + type Envelope, + type EnvelopeType, + type Hex, + PROTOCOL_VERSION, + type SignMessage, + type UnsignedEnvelope, +} from './types' + +/** PSS topics, spec §5.1. */ +export function providerTopic(wallet: Hex): string { + return `t4t:provider:${wallet.toLowerCase()}` +} +export function clientTopic(wallet: Hex): string { + return `t4t:client:${wallet.toLowerCase()}` +} + +export function randomNonce(): Hex { + const bytes = new Uint8Array(16) + crypto.getRandomValues(bytes) + return toHex(bytes) +} + +/** + * Deterministic JSON encoding so signatures are stable across implementations. + * Sorts keys at every level and emits no whitespace. + */ +export function canonicalize(value: unknown): string { + if (value === null) return 'null' + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new Error('non-finite number') + return JSON.stringify(value) + } + if (typeof value === 'bigint') return JSON.stringify(value.toString()) + if (typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value) + if (Array.isArray(value)) return '[' + value.map(canonicalize).join(',') + ']' + if (typeof value === 'object') { + const obj = value as Record + const keys = Object.keys(obj).sort() + return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalize(obj[k])).join(',') + '}' + } + throw new Error(`unserializable: ${typeof value}`) +} + +export interface BuildEnvelopeArgs { + from: Hex + to: Hex + type: EnvelopeType + body: TBody + ts?: number + nonce?: Hex +} + +export async function signEnvelope( + args: BuildEnvelopeArgs, + signMessage: SignMessage, +): Promise> { + const unsigned: UnsignedEnvelope = { + v: PROTOCOL_VERSION, + type: args.type, + from: args.from, + to: args.to, + ts: args.ts ?? Math.floor(Date.now() / 1000), + nonce: args.nonce ?? randomNonce(), + body: args.body, + } + const sig = await signMessage(canonicalize(unsigned)) + return {...unsigned, sig} +} + +/** True iff the signature recovers to `env.from` and the version matches. */ +export async function verifyEnvelope(env: Envelope): Promise { + if (env.v !== PROTOCOL_VERSION) return false + const {sig, ...unsigned} = env + try { + const recovered = await recoverMessageAddress({ + message: canonicalize(unsigned), + signature: sig, + }) + return isAddressEqual(recovered, env.from) + } catch { + return false + } +} + +export function encodeEnvelope(env: Envelope): Uint8Array { + return new TextEncoder().encode(JSON.stringify(env)) +} + +export function decodeEnvelope(bytes: Uint8Array | string): Envelope { + const text = typeof bytes === 'string' ? bytes : new TextDecoder().decode(bytes) + return JSON.parse(text) as Envelope +} + +/** + * LRU-ish dedup cache for incoming envelopes. Per spec §9, hold the last 10k + * nonces and drop on repeat. + */ +export class DedupCache { + private readonly seen = new Set() + constructor(private readonly capacity = 10_000) {} + + has(key: string): boolean { + return this.seen.has(key) + } + + mark(key: string): void { + if (this.seen.size >= this.capacity) { + const oldest = this.seen.values().next().value + if (oldest) this.seen.delete(oldest) + } + this.seen.add(key) + } +} + +/** Stable per-envelope dedup key: `from:nonce`. */ +export function envelopeKey(env: Pick): string { + return `${env.from.toLowerCase()}:${env.nonce.toLowerCase()}` +} diff --git a/src/t4t/lib/keys.ts b/src/t4t/lib/keys.ts new file mode 100644 index 0000000..f558655 --- /dev/null +++ b/src/t4t/lib/keys.ts @@ -0,0 +1,113 @@ +import {secp256k1} from '@noble/curves/secp256k1' +import {hkdf} from '@noble/hashes/hkdf' +import {sha256} from '@noble/hashes/sha2' +import {bytesToHex, hexToBytes, randomBytes} from '@noble/hashes/utils' +import {chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs' +import {dirname} from 'node:path' +import type {Hex} from './types' + +/** + * The on-chain registry stores `pssPublicKey` as `bytes32` (spec §4 / contract). + * A real secp256k1 compressed public key is 33 bytes (1-byte Y-parity prefix + + * 32-byte X). We resolve the mismatch by only minting keys with even-Y parity + * — the X coordinate alone is then enough; senders prepend 0x02 to rebuild + * the compressed form. Rejection-sampling reaches even-Y in ~2 tries on + * average without leaking entropy. + */ +const PSS_INFO = new TextEncoder().encode('t4t-pss-v1') + +export interface PssKeypair { + /** 32-byte secp256k1 private scalar. */ + privateKey: Hex + /** 32-byte X coordinate of the compressed pub key (Y is even by construction). */ + publicKeyX: Hex +} + +/** + * Generate a fresh random PSS keypair with even-Y parity. Used at first + * startup; the result is persisted via {@link loadOrCreatePssKey}. + */ +export function generatePssKeypair(): PssKeypair { + for (let i = 0; i < 256; i++) { + const candidate = randomBytes(32) + if (!secp256k1.utils.isValidPrivateKey(candidate)) continue + const pub = secp256k1.getPublicKey(candidate, true) + if (pub[0] === 0x02) return pubFromPriv(candidate, pub) + } + throw new Error('failed to generate an even-Y PSS keypair') +} + +/** + * Deterministically derive a PSS keypair from the wallet's private key. + * + * Retained for tests and as a one-shot migration helper for operators who + * registered with the wallet-derived key in the previous container version. + * **Not used in production startup** — see `loadOrCreatePssKey`. + */ +export function derivePssKeypair(walletKey: Hex): PssKeypair { + const ikm = hexToBytes(walletKey.replace(/^0x/, '')) + for (let counter = 0; counter < 256; counter++) { + const salt = new Uint8Array([counter]) + const candidate = hkdf(sha256, ikm, salt, PSS_INFO, 32) + if (!secp256k1.utils.isValidPrivateKey(candidate)) continue + const pub = secp256k1.getPublicKey(candidate, true) + if (pub[0] === 0x02) return pubFromPriv(candidate, pub) + } + throw new Error('failed to derive an even-Y PSS keypair') +} + +/** Public-key-only accessor for code paths that don't need the secret. */ +export function pssPubKeyFromWallet(walletKey: Hex): Hex { + return derivePssKeypair(walletKey).publicKeyX +} + +/** Reconstruct the 33-byte compressed pubkey from the on-chain 32-byte X. */ +export function pssPubKeyXToCompressed(pubKeyX: Hex): Uint8Array { + const x = hexToBytes(pubKeyX.replace(/^0x/, '')) + if (x.length !== 32) throw new Error(`expected 32-byte X coord, got ${x.length}`) + const out = new Uint8Array(33) + out[0] = 0x02 // even-Y by convention + out.set(x, 1) + return out +} + +/** + * Load the PSS keypair from `path`, or generate + persist one if absent. + * + * The on-disk format is a single line of 64 hex chars (the 32-byte private + * scalar). The file is created mode 0600 and its parent directory is created + * recursively if missing. The PSS pubkey is derived on load and validated to + * have even-Y parity — files written by older versions that picked a key + * with odd Y will be rejected so the operator can re-issue rather than + * silently producing keys incompatible with the bytes32 registry slot. + */ +export function loadOrCreatePssKey(path: string): PssKeypair { + if (existsSync(path)) return parsePssKeyFile(readFileSync(path, 'utf8')) + const fresh = generatePssKeypair() + mkdirSync(dirname(path), {recursive: true}) + const hex = fresh.privateKey.replace(/^0x/, '') + writeFileSync(path, hex + '\n', {mode: 0o600}) + try { chmodSync(path, 0o600) } catch { /* best-effort on platforms without chmod */ } + return fresh +} + +function parsePssKeyFile(contents: string): PssKeypair { + const hex = contents.trim().replace(/^0x/, '') + if (!/^[0-9a-fA-F]{64}$/.test(hex)) { + throw new Error('PSS key file must contain a 64-hex-char (32-byte) private scalar') + } + const priv = hexToBytes(hex) + if (!secp256k1.utils.isValidPrivateKey(priv)) throw new Error('invalid secp256k1 private key') + const pub = secp256k1.getPublicKey(priv, true) + if (pub[0] !== 0x02) { + throw new Error('PSS key has odd-Y public point; registry expects even-Y. Regenerate the key.') + } + return pubFromPriv(priv, pub) +} + +function pubFromPriv(priv: Uint8Array, pub: Uint8Array): PssKeypair { + return { + privateKey: ('0x' + bytesToHex(priv)) as Hex, + publicKeyX: ('0x' + bytesToHex(pub.subarray(1))) as Hex, + } +} diff --git a/src/t4t/lib/logger.ts b/src/t4t/lib/logger.ts new file mode 100644 index 0000000..6d23933 --- /dev/null +++ b/src/t4t/lib/logger.ts @@ -0,0 +1,4 @@ +// VENDORED — local edit (allowed by drift policy): re-export the project logger +// so vendored t4t code shares the same pino instance as the rest of the service. +export { logger } from '../../logger'; +export type { Logger } from '../../logger'; diff --git a/src/t4t/lib/stamps.ts b/src/t4t/lib/stamps.ts new file mode 100644 index 0000000..0e2fb6d --- /dev/null +++ b/src/t4t/lib/stamps.ts @@ -0,0 +1,292 @@ +/** + * Container-managed Swarm postage stamps. + * + * Pure, mode-agnostic helpers wrapping the Bee `/stamps` and `/chainstate` + * endpoints. The provider and gateway modes both call `ensureManagedStamp()` + * on boot and `topUpIfBelow()` on a background tick. + * + * Operator intent (depth + TTL) is configured via the `T4T_STAMP_*` env vars + * in `config.ts`; the raw `amount` (per-chunk wei budget) is derived from + * `getChainState().currentPrice × blocksPerDay × ttlDays`. + * + * If `POSTAGE_BATCH_ID` is set, that escape hatch wins and this module is + * never consulted — the operator owns the batch lifecycle. + */ + +import type {Bee} from '@ethersphere/bee-js' +import type {Logger} from './logger' + +// Gnosis Chain block time is ~5s; 86400/5 = 17280. +export const BLOCKS_PER_DAY_GNOSIS = 17280n + +export interface StampManageOpts { + depth: number + ttlDays: number + minTtlDays: number + label: string + dryRun: boolean +} + +export interface ManagedStamp { + batchID: string + /** Source of the resolved id, for log/UI display. */ + source: 'env' | 'reused' | 'bought' | 'discovered' + depth: number + remainingDays: number + usable: boolean + utilization: number + usage: number + usageText: string + label: string +} + +/** xBZZ cost to buy/top-up a stamp = amount × 2^depth (Bee charges the node + * wallet, not the container's wallet). Used for log/UI cost preview only. */ +export function stampWalletCostWei(amount: bigint, depth: number): bigint { + return amount * (1n << BigInt(depth)) +} + +/** Wei budget per chunk per block, multiplied across the TTL window — the + * value Bee expects as `amount` in POST /stamps/{amount}/{depth}. */ +export async function amountForTtl(bee: Bee, ttlDays: number): Promise { + const state = await bee.getChainState() + const price = BigInt(state.currentPrice) + if (price <= 0n) throw new Error(`Bee returned non-positive chainstate.currentPrice (${state.currentPrice})`) + return price * BLOCKS_PER_DAY_GNOSIS * BigInt(Math.max(1, Math.trunc(ttlDays))) +} + +interface BatchLike { + batchID: {toString(): string} + label: string + usable: boolean + depth: number + utilization: number + usage: number + usageText: string + duration: {toDays(): number} +} + +export function summariseBatch(b: BatchLike, source: ManagedStamp['source']): ManagedStamp { + return { + batchID: b.batchID.toString(), + source, + depth: b.depth, + remainingDays: b.duration.toDays(), + usable: b.usable, + utilization: b.utilization, + usage: b.usage, + usageText: b.usageText, + label: b.label, + } +} + +/** Pick the best `t4t-managed` batch the node already owns: must be usable + * and have remaining TTL ≥ minTtlDays. Tiebreak by longest remaining TTL + * so we converge on the youngest batch when an old one is about to expire. */ +export function pickReusable( + all: T[], + label: string, + minTtlDays: number, +): T | null { + const candidates = all + .filter(b => b.label === label && b.usable && b.duration.toDays() >= minTtlDays) + .sort((a, b) => b.duration.toDays() - a.duration.toDays()) + return candidates[0] ?? null +} + +/** Same label but TTL too short — caller can top up rather than buy fresh. */ +export function pickToppable( + all: T[], + label: string, +): T | null { + const candidates = all + .filter(b => b.label === label && b.usable) + .sort((a, b) => b.duration.toDays() - a.duration.toDays()) + return candidates[0] ?? null +} + +/** List all batches via Bee — typed loosely so it adapts to bee-js shape + * changes (the module only cares about a handful of fields). */ +async function listAllBatches(bee: Bee): Promise { + return (await bee.getAllPostageBatch()) as unknown as BatchLike[] +} + +export interface EnsureStampDeps { + bee: Bee + logger: Logger + opts: StampManageOpts +} + +/** + * Resolve a managed batch — list, reuse, top-up-then-reuse, or buy. + * Idempotent: safe to call from many container instances against the same Bee + * node; they converge on the longest-lived `label` batch. + */ +export async function ensureManagedStamp(deps: EnsureStampDeps): Promise { + const {bee, logger, opts} = deps + const log = logger.child({stamps: true}) + + const all = await listAllBatches(bee) + + const reusable = pickReusable(all, opts.label, opts.minTtlDays) + if (reusable) { + log.info( + {batchID: reusable.batchID.toString(), remainingDays: reusable.duration.toDays(), depth: reusable.depth}, + 'reusing existing t4t-managed batch', + ) + return summariseBatch(reusable, 'reused') + } + + const toppable = pickToppable(all, opts.label) + if (toppable) { + if (opts.dryRun) { + log.warn({batchID: toppable.batchID.toString()}, 'dry-run: would top up existing batch instead of buying') + return summariseBatch(toppable, 'reused') + } + const amount = await amountForTtl(bee, opts.ttlDays) + log.info( + { + batchID: toppable.batchID.toString(), + amount: amount.toString(), + walletCost: stampWalletCostWei(amount, toppable.depth).toString(), + }, + 'topping up under-TTL t4t-managed batch instead of buying new', + ) + await bee.topUpBatch(toppable.batchID.toString(), amount) + const refreshed = (await listAllBatches(bee)).find( + b => b.batchID.toString() === toppable.batchID.toString(), + ) + return summariseBatch(refreshed ?? toppable, 'reused') + } + + // No reusable batch — buy fresh. + const amount = await amountForTtl(bee, opts.ttlDays) + const walletCost = stampWalletCostWei(amount, opts.depth) + log.info( + { + depth: opts.depth, + ttlDays: opts.ttlDays, + amount: amount.toString(), + walletCost: walletCost.toString(), + label: opts.label, + dryRun: opts.dryRun, + }, + opts.dryRun ? 'dry-run: would buy postage batch' : 'buying new postage batch', + ) + if (opts.dryRun) { + throw new Error( + 'T4T_STAMP_DRY_RUN=true and no reusable batch exists — refusing to buy. ' + + 'Disable dry-run or pre-purchase a batch labelled "' + opts.label + '".', + ) + } + const batchId = await bee.createPostageBatch(amount, opts.depth, { + label: opts.label, + waitForUsable: true, + }) + const idStr = batchId.toString() + log.info({batchID: idStr}, 'postage batch bought and usable') + const refreshed = (await listAllBatches(bee)).find(b => b.batchID.toString() === idStr) + if (!refreshed) { + // Bee returned the id but list missed it — return a minimal stub. + return { + batchID: idStr, + source: 'bought', + depth: opts.depth, + remainingDays: opts.ttlDays, + usable: true, + utilization: 0, + usage: 0, + usageText: '0%', + label: opts.label, + } + } + return summariseBatch(refreshed, 'bought') +} + +export interface TopUpIfBelowDeps { + bee: Bee + logger: Logger + batchId: string + ttlDays: number + minTtlDays: number + /** Auto-dilute (raise depth by +1) when utilization crosses this fraction. + * Doubles bucket capacity but halves remaining TTL, so we top up first when + * TTL is also under threshold. Set to 1.0 to disable. */ + maxUtilization: number + /** Hard cap so an unexpected utilization spike can't run depth to infinity. */ + maxDepth: number + dryRun: boolean +} + +/** Background tick: top up if remaining TTL falls below `minTtlDays`, and + * dilute if utilization crosses `maxUtilization`. Returns the post-tick + * remaining-days for the admin UI to surface. */ +export async function topUpIfBelow(deps: TopUpIfBelowDeps): Promise<{toppedUp: boolean; diluted: boolean; remainingDays: number}> { + const all = await listAllBatches(deps.bee) + let batch = all.find(b => b.batchID.toString() === deps.batchId) + if (!batch) { + deps.logger.warn({batchId: deps.batchId}, 'managed batch not found on Bee — skipping top-up tick') + return {toppedUp: false, diluted: false, remainingDays: 0} + } + let toppedUp = false + let diluted = false + + // Top-up first — diluting halves remaining TTL, so if we're already short + // we'd push the batch below the floor. + const remaining = batch.duration.toDays() + if (remaining < deps.minTtlDays) { + if (deps.dryRun) { + deps.logger.warn({batchId: deps.batchId, remaining, threshold: deps.minTtlDays}, 'dry-run: would auto-top-up') + } else { + const amount = await amountForTtl(deps.bee, deps.ttlDays) + deps.logger.info( + { + batchId: deps.batchId, + remaining, + threshold: deps.minTtlDays, + amount: amount.toString(), + walletCost: stampWalletCostWei(amount, batch.depth).toString(), + }, + 'auto-topping up managed batch', + ) + await deps.bee.topUpBatch(deps.batchId, amount) + toppedUp = true + batch = (await listAllBatches(deps.bee)).find(b => b.batchID.toString() === deps.batchId) ?? batch + } + } + + // Auto-dilute on high utilization. Bee tracks the most-loaded bucket; one + // hot bucket can spike utilization long before "total bytes" is anywhere + // near full, so this kicks in earlier than the operator expects. + if (batch.usage >= deps.maxUtilization && batch.depth < deps.maxDepth) { + const newDepth = batch.depth + 1 + if (deps.dryRun) { + deps.logger.warn({batchId: deps.batchId, usage: batch.usage, from: batch.depth, to: newDepth}, 'dry-run: would auto-dilute') + } else { + deps.logger.info({batchId: deps.batchId, usage: batch.usage, from: batch.depth, to: newDepth}, 'auto-diluting managed batch') + await deps.bee.diluteBatch(deps.batchId, newDepth) + diluted = true + batch = (await listAllBatches(deps.bee)).find(b => b.batchID.toString() === deps.batchId) ?? batch + } + } + + return {toppedUp, diluted, remainingDays: batch.duration.toDays()} +} + +/** Manual top-up triggered from the admin UI. Amount is derived from the + * same TTL math so the UI just asks for "days" and the math stays one place. */ +export async function manualTopUp( + bee: Bee, + batchId: string, + ttlDays: number, +): Promise { + const amount = await amountForTtl(bee, ttlDays) + await bee.topUpBatch(batchId, amount) + return amount +} + +/** Manual dilute (grow capacity) triggered from the admin UI. Halves + * remaining TTL per +1 depth — caller should confirm in UI. */ +export async function manualDilute(bee: Bee, batchId: string, newDepth: number): Promise { + await bee.diluteBatch(batchId, newDepth) +} diff --git a/src/t4t/lib/swarm.ts b/src/t4t/lib/swarm.ts new file mode 100644 index 0000000..dca436b --- /dev/null +++ b/src/t4t/lib/swarm.ts @@ -0,0 +1,178 @@ +import {Bee, Topic, type PssSubscription} from '@ethersphere/bee-js' +import type {Envelope, Hex} from './types' +import {decodeEnvelope, encodeEnvelope, envelopeKey, DedupCache, verifyEnvelope} from './envelope' +import type {Logger} from './logger' + +export interface SwarmClientOpts { + bee: Bee + postageBatchId: string + logger: Logger +} + +/** Returns true if the Bee error means "the postage batch ran out of bucket + * capacity" (HTTP 402 with `code:402, message:"batch is overissued"`). */ +function isBatchOverissued(err: unknown): boolean { + const e = err as {status?: number; responseBody?: {message?: string; code?: number}} | null + if (!e || e.status !== 402) return false + const msg = e.responseBody?.message ?? '' + return /overissued|insufficient/i.test(msg) || e.responseBody?.code === 402 +} + +/** Emergency self-heal: when a Bee call fails because the batch is full, + * dilute it by +1 depth (doubles bucket capacity, halves remaining TTL) and + * retry the call exactly once. Logged so the operator sees the recovery. + * Bee handles the dilute tx from its own wallet — we don't pay xBZZ here. */ +const EMERGENCY_DILUTE_MAX_DEPTH = 28 +async function withBatchRecovery(opts: SwarmClientOpts, fn: () => Promise): Promise { + try { + return await fn() + } catch (err) { + if (!isBatchOverissued(err)) throw err + const batches = await opts.bee.getAllPostageBatch().catch(() => []) + const batch = batches.find(b => b.batchID.toString() === opts.postageBatchId) + if (!batch) throw err + if (batch.depth >= EMERGENCY_DILUTE_MAX_DEPTH) { + opts.logger.error({batchId: opts.postageBatchId, depth: batch.depth}, 'batch overissued and at depth cap — cannot self-heal') + throw err + } + const newDepth = batch.depth + 1 + opts.logger.warn( + {batchId: opts.postageBatchId, from: batch.depth, to: newDepth}, + 'batch overissued — emergency dilute then retry', + ) + await opts.bee.diluteBatch(opts.postageBatchId, newDepth) + // Bee's diluteBatch returns when the tx is SUBMITTED, not mined. The local + // node won't accept writes at the new depth until the tx confirms on + // Gnosis (~5-15s). Poll until the batch's depth actually reflects the + // dilute, with a 30s ceiling. Without this, the retry races the chain and + // hits the same 402. + const deadline = Date.now() + 30_000 + while (Date.now() < deadline) { + const refreshed = await opts.bee.getAllPostageBatch().catch(() => []) + const b = refreshed.find(x => x.batchID.toString() === opts.postageBatchId) + if (b && b.depth >= newDepth) { + opts.logger.info({batchId: opts.postageBatchId, depth: b.depth}, 'dilute confirmed') + break + } + await new Promise(r => setTimeout(r, 1500)) + } + return await fn() + } +} + +/** Upload a single chunk and return its Swarm reference (hex). */ +export async function uploadChunk( + opts: SwarmClientOpts, + bytes: Uint8Array, +): Promise { + return withBatchRecovery(opts, async () => { + const {reference} = await opts.bee.uploadData(opts.postageBatchId, bytes) + return reference.toString() + }) +} + +export async function downloadChunk(opts: SwarmClientOpts, reference: string): Promise { + const data = await opts.bee.downloadData(reference) + return data.toUint8Array() +} + +/** Pick the most usable postage batch the Bee node already owns. We prefer + * batches that Bee marks as `usable: true` (i.e. enough block confirmations + * and not expired), with the largest remaining capacity as a tiebreaker. + * Returns null when the node has no usable batch — the caller should surface + * this so the operator can buy or top-up one. */ +export async function discoverUsableBatchId(bee: Bee): Promise { + const all = await bee.getAllPostageBatch().catch(() => []) + const usable = all.filter(b => b.usable) + if (usable.length === 0) return null + // Pick the longest-lived as a proxy for "still valid for the next while". + usable.sort((a, b) => b.duration.toSeconds() - a.duration.toSeconds()) + return usable[0]!.batchID.toString() +} + +export interface PssTransportOpts extends SwarmClientOpts { + selfAddress: Hex + dedupCapacity?: number +} + +export interface PssSendArgs { + topic: string + /** Target's Swarm overlay address (hex, 0x-prefixed). */ + recipientOverlay: Hex + /** Recipient's PSS public key (hex, 0x-prefixed compressed pubkey). */ + recipientPssKey: Hex + envelope: Envelope +} + +export interface PssSubscribeArgs { + topic: string + onEnvelope: (env: Envelope) => void | Promise + onError?: (err: unknown) => void +} + +/** + * Thin wrapper over bee-js PSS that signs nothing and verifies everything. + * Envelope signing happens upstream in `envelope.ts`; this layer only routes. + */ +export class PssTransport { + private readonly dedup: DedupCache + + constructor(private readonly opts: PssTransportOpts) { + this.dedup = new DedupCache(opts.dedupCapacity ?? 10_000) + } + + async send(args: PssSendArgs): Promise { + const topic = Topic.fromString(args.topic) + // First 2 bytes of the overlay narrow PSS forwarding without doxxing + // the full target address (Bee convention). + const target = args.recipientOverlay.slice(2, 6) + // The on-chain registry stores `pssPublicKey` as bytes32 (X coord only) + // and our keygen guarantees even-Y parity (see lib/keys.ts), so prepend + // 0x02 to rebuild the 33-byte compressed pubkey form bee-js' PublicKey + // class accepts. Passing bare 32 bytes throws + // "Bytes#checkByteLength: bytes length is 32 but expected 64". + const x = args.recipientPssKey.replace(/^0x/, '') + if (x.length !== 64) { + throw new Error(`recipient PSS pubkey must be 32-byte X coord (got ${x.length / 2} bytes)`) + } + const pubKey = '02' + x + await withBatchRecovery(this.opts, () => + this.opts.bee.pssSend( + this.opts.postageBatchId, + topic, + target, + encodeEnvelope(args.envelope), + pubKey, + ), + ) + } + + subscribe(args: PssSubscribeArgs): PssSubscription { + const topic = Topic.fromString(args.topic) + return this.opts.bee.pssSubscribe(topic, { + onMessage: async msg => { + try { + const env = decodeEnvelope(msg.toUtf8()) + const key = envelopeKey(env) + if (this.dedup.has(key)) return + if (!(await verifyEnvelope(env))) { + this.opts.logger.warn({from: env.from}, 'envelope signature verification failed') + return + } + this.dedup.mark(key) + await args.onEnvelope(env) + } catch (err) { + args.onError?.(err) + this.opts.logger.error({err}, 'pss decode failure') + } + }, + onError: err => { + args.onError?.(err) + this.opts.logger.error({err}, 'pss subscription error') + }, + onClose: () => { + this.opts.logger.warn({topic: args.topic}, 'pss subscription closed') + }, + }) + } +} diff --git a/src/t4t/lib/types.ts b/src/t4t/lib/types.ts new file mode 100644 index 0000000..9457135 --- /dev/null +++ b/src/t4t/lib/types.ts @@ -0,0 +1,115 @@ +import type {Hex} from 'viem' + +export type {Hex} + +export const PROTOCOL_VERSION = 1 as const + +export type EnvelopeType = 'job_notify' | 'job_ack' | 'job_deliver' + +export interface UnsignedEnvelope { + v: typeof PROTOCOL_VERSION + type: EnvelopeType + from: Hex + to: Hex + ts: number + nonce: Hex + body: TBody +} + +export interface Envelope extends UnsignedEnvelope { + sig: Hex +} + +// ---------- Body payloads (spec §5.3) ---------- + +export interface JobNotifyBody { + jobId: Hex + requestHash: string + modelId: string + /** xBZZ wei as a base-10 string to survive JSON. */ + maxPayment: string + deliveryDeadline: number + /** Gateway's PSS pubkey (32-byte X coord, even-Y by convention). + * Required so the provider can encrypt the ACK/deliver back without + * looking up the gateway's address in the on-chain registry (gateways + * aren't registered there). */ + clientPssPubKey: Hex + /** Gateway's Bee overlay for PSS routing. */ + clientSwarmOverlay: Hex +} + +export interface JobAckBody { + jobId: Hex + estimatedCompletion: number +} + +export interface JobDeliverBody { + jobId: Hex + responseHash: string +} + +// ---------- On-chain mirror types ---------- + +export interface ProviderRow { + owner: Hex + pssPublicKey: Hex + swarmOverlay: Hex + metadataURI: string + stake: bigint + lastHeartbeat: number + totalJobs: number + successfulJobs: number + active: boolean +} + +export interface ModelOffering { + modelId: string + inputPricePerMillionTokens: bigint + outputPricePerMillionTokens: bigint + maxContextTokens: bigint + maxLatencySeconds: bigint +} + +// ---------- Swarm payload (spec §6) ---------- + +export interface OpenAIChatRequest { + model: string + messages: Array<{role: 'system' | 'user' | 'assistant' | 'tool'; content: string}> + temperature?: number + max_tokens?: number + stream?: boolean + [k: string]: unknown +} + +export interface OpenAIChatResponse { + id: string + object: 'chat.completion' + created: number + model: string + choices: Array<{ + index: number + message: {role: 'assistant'; content: string} + finish_reason: string + }> + usage?: {prompt_tokens: number; completion_tokens: number; total_tokens: number} +} + +export interface RequestPayload { + v: typeof PROTOCOL_VERSION + jobId: Hex + client: Hex + modelId: string + openaiRequest: OpenAIChatRequest + clientPssPubKey: Hex + ts: number +} + +export interface ResponsePayload { + v: typeof PROTOCOL_VERSION + jobId: Hex + provider: Hex + openaiResponse: OpenAIChatResponse + ts: number +} + +export type SignMessage = (message: string) => Promise diff --git a/src/t4t/lib/wallet.ts b/src/t4t/lib/wallet.ts new file mode 100644 index 0000000..6169c04 --- /dev/null +++ b/src/t4t/lib/wallet.ts @@ -0,0 +1,54 @@ +import {existsSync, readFileSync, writeFileSync, chmodSync, mkdirSync} from 'node:fs' +import {dirname} from 'node:path' +import {validateMnemonic, mnemonicToSeedSync} from '@scure/bip39' +import {wordlist} from '@scure/bip39/wordlists/english' +import {HDKey} from '@scure/bip32' +import {generateMnemonic, english} from 'viem/accounts' +import {toHex} from 'viem' +import type {Hex} from './types' + +/** Standard Ethereum derivation path — first account, first address. */ +const ETH_PATH = "m/44'/60'/0'/0/0" + +export function newMnemonic(): string { + return generateMnemonic(english) +} + +export function isMnemonic(input: string): boolean { + const words = input.trim().split(/\s+/) + if (![12, 15, 18, 21, 24].includes(words.length)) return false + return validateMnemonic(words.join(' '), wordlist) +} + +export function mnemonicToPrivateKey(mnemonic: string): Hex { + if (!isMnemonic(mnemonic)) throw new Error('invalid BIP39 mnemonic') + const seed = mnemonicToSeedSync(mnemonic.trim().replace(/\s+/g, ' ')) + const derived = HDKey.fromMasterSeed(seed).derive(ETH_PATH) + if (!derived.privateKey) throw new Error('mnemonic derived no private key') + return toHex(derived.privateKey) as Hex +} + +/** Accepts a BIP39 mnemonic OR a 0x-prefixed 32-byte private key. */ +export function parseWalletInput(input: string): Hex { + const trimmed = input.trim() + if (/^0x[a-fA-F0-9]{64}$/.test(trimmed)) return trimmed as Hex + return mnemonicToPrivateKey(trimmed) +} + +export function walletFileExists(path: string): boolean { + return existsSync(path) +} + +export function loadWalletKey(path: string): Hex { + const raw = readFileSync(path, 'utf8').trim() + if (!/^0x[a-fA-F0-9]{64}$/.test(raw)) throw new Error(`wallet file at ${path} is not a valid private key`) + return raw as Hex +} + +export function saveWalletKey(path: string, key: Hex): void { + if (!/^0x[a-fA-F0-9]{64}$/.test(key)) throw new Error('refusing to save: not a 0x-prefixed 32-byte private key') + mkdirSync(dirname(path), {recursive: true}) + writeFileSync(path, key + '\n', {mode: 0o600}) + // writeFileSync's mode is only respected on file creation; tighten existing files too. + chmodSync(path, 0o600) +} diff --git a/src/t4t/modes/gateway/models.ts b/src/t4t/modes/gateway/models.ts new file mode 100644 index 0000000..e6d35ea --- /dev/null +++ b/src/t4t/modes/gateway/models.ts @@ -0,0 +1,130 @@ +import type {ChainClient} from '../../lib/chain' +import {getOfferings, listProviders} from '../../lib/chain' +import type {ModelOffering, ProviderRow} from '../../lib/types' +import {isHeartbeatFresh} from './selector' + +export interface ModelSummary { + /** OpenAI-style model id (e.g. "llama3:8b-instruct"). */ + id: string + /** Active, heartbeat-fresh providers advertising this model. */ + providerCount: number + /** Min input price (xBZZ wei per 1M tokens) across providers. */ + minInputPrice: bigint + /** Median input price. */ + medianInputPrice: bigint + /** Min output price (xBZZ wei per 1M tokens) across providers. */ + minOutputPrice: bigint + /** Median output price. */ + medianOutputPrice: bigint + /** Worst declared SLA across the offering set, in seconds. */ + slowestSlaSeconds: number +} + +export interface DiscoverDeps { + chain: ChainClient + allowedModels?: string[] + minProvidersPerModel: number + cacheTtlSeconds: number + now?: () => number +} + +/** A provider plus the model offerings it currently advertises. */ +export interface ProviderListing { + provider: ProviderRow + offerings: ModelOffering[] +} + +interface CacheEntry { + expiresAt: number + data: T +} + +export class ModelDiscovery { + private modelsCache: CacheEntry | null = null + private providersCache: CacheEntry | null = null + + constructor(private readonly deps: DiscoverDeps) {} + + async list(): Promise { + const now = (this.deps.now ?? Date.now)() + if (this.modelsCache && this.modelsCache.expiresAt > now) return this.modelsCache.data + const {summaries} = await this.scan() + this.modelsCache = {data: summaries, expiresAt: now + this.deps.cacheTtlSeconds * 1000} + return summaries + } + + async listProviders(): Promise { + const now = (this.deps.now ?? Date.now)() + if (this.providersCache && this.providersCache.expiresAt > now) return this.providersCache.data + const {providers} = await this.scan() + this.providersCache = {data: providers, expiresAt: now + this.deps.cacheTtlSeconds * 1000} + return providers + } + + invalidate(): void { + this.modelsCache = null + this.providersCache = null + } + + private async scan(): Promise<{summaries: ModelSummary[]; providers: ProviderListing[]}> { + const allow = this.deps.allowedModels + const nowSec = Math.floor(((this.deps.now ?? Date.now)()) / 1000) + const byModel = new Map() + const providers: ProviderListing[] = [] + + let cursor = 0n + for (let i = 0; i < 20; i++) { + const {page, nextCursor} = await listProviders(this.deps.chain, cursor, 50n) + for (const p of page) { + if (!isUsable(p, nowSec)) continue + const offerings = await getOfferings(this.deps.chain, p.owner) + const visible = allow ? offerings.filter(o => allow.includes(o.modelId)) : offerings + if (visible.length > 0) providers.push({provider: p, offerings: visible}) + for (const o of visible) mergeOffering(byModel, o) + } + if (nextCursor === cursor || page.length === 0) break + cursor = nextCursor + } + + const summaries: ModelSummary[] = [] + for (const [id, agg] of byModel) { + if (agg.count < this.deps.minProvidersPerModel) continue + summaries.push({ + id, + providerCount: agg.count, + minInputPrice: agg.inputPrices.reduce((a, b) => (a < b ? a : b)), + medianInputPrice: median(agg.inputPrices), + minOutputPrice: agg.outputPrices.reduce((a, b) => (a < b ? a : b)), + medianOutputPrice: median(agg.outputPrices), + slowestSlaSeconds: agg.slowest, + }) + } + summaries.sort((a, b) => a.id.localeCompare(b.id)) + providers.sort((a, b) => a.provider.owner.localeCompare(b.provider.owner)) + return {summaries, providers} + } +} + +function isUsable(p: ProviderRow, nowSec: number): boolean { + if (!p.active) return false + return isHeartbeatFresh(p.lastHeartbeat, nowSec) +} + +function mergeOffering( + byModel: Map, + o: ModelOffering, +): void { + const slot = byModel.get(o.modelId) ?? {inputPrices: [], outputPrices: [], slowest: 0, count: 0} + slot.inputPrices.push(o.inputPricePerMillionTokens) + slot.outputPrices.push(o.outputPricePerMillionTokens) + slot.slowest = Math.max(slot.slowest, Number(o.maxLatencySeconds)) + slot.count += 1 + byModel.set(o.modelId, slot) +} + +function median(values: bigint[]): bigint { + const sorted = [...values].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) + const mid = Math.floor(sorted.length / 2) + if (sorted.length % 2 === 1) return sorted[mid]! + return (sorted[mid - 1]! + sorted[mid]!) / 2n +} diff --git a/src/t4t/modes/gateway/selector.ts b/src/t4t/modes/gateway/selector.ts new file mode 100644 index 0000000..832a778 --- /dev/null +++ b/src/t4t/modes/gateway/selector.ts @@ -0,0 +1,114 @@ +import type {Address} from 'viem' +import type {ChainClient} from '../../lib/chain' +import {getOfferings, listProviders} from '../../lib/chain' +import type {ModelOffering, ProviderRow} from '../../lib/types' + +export type SelectionStrategy = 'cheapest' | 'top_rep_cheapest' | 'manual' + +export interface SelectionContext { + modelId: string + /** Optional cap on (input + output) xBZZ wei per 1M tokens combined. */ + maxPrice?: bigint + /** Required when strategy = 'manual'. */ + manualProvider?: Address +} + +function combinedPrice(o: ModelOffering): bigint { + return o.inputPricePerMillionTokens + o.outputPricePerMillionTokens +} + +export interface CandidateProvider { + provider: ProviderRow + offering: ModelOffering +} + +const MIN_TOTAL_JOBS = 20 +const MIN_SUCCESS_RATE = 0.95 +/** Share of routes given to new providers so they can build reputation. */ +const EXPLORATION_SHARE = 0.05 +/** Matches `ProviderRegistry.HEARTBEAT_TTL`. Providers stale past this drop + * from the selectable set, even if `active` is still true on-chain. */ +const HEARTBEAT_TTL_SECONDS = 600 + +/** + * Pull the active provider set, filter to those that offer the requested model + * within budget, then rank per the selected strategy. See spec §8. + */ +export async function selectProvider( + chain: ChainClient, + strategy: SelectionStrategy, + ctx: SelectionContext, +): Promise { + if (strategy === 'manual') { + if (!ctx.manualProvider) throw new Error('manual strategy requires T4T_MANUAL_PROVIDER') + return matchOffering(chain, ctx.manualProvider, ctx) + } + + const candidates: CandidateProvider[] = [] + const now = Math.floor(Date.now() / 1000) + let cursor = 0n + for (let i = 0; i < 20; i++) { + const {page, nextCursor} = await listProviders(chain, cursor, 50n) + for (const provider of page) { + if (!provider.active) continue + if (!isHeartbeatFresh(provider.lastHeartbeat, now)) continue + const offerings = await getOfferings(chain, provider.owner) + const offering = offerings.find( + o => + o.modelId === ctx.modelId && + (ctx.maxPrice === undefined || combinedPrice(o) <= ctx.maxPrice), + ) + if (offering) candidates.push({provider, offering}) + } + if (nextCursor === cursor || page.length === 0) break + cursor = nextCursor + } + return rank(candidates, strategy) +} + +export function isHeartbeatFresh(lastHeartbeat: number | bigint, nowSeconds: number): boolean { + const last = typeof lastHeartbeat === 'bigint' ? Number(lastHeartbeat) : lastHeartbeat + if (last === 0) return false + return last + HEARTBEAT_TTL_SECONDS >= nowSeconds +} + +async function matchOffering( + chain: ChainClient, + owner: Address, + ctx: SelectionContext, +): Promise { + const offerings = await getOfferings(chain, owner) + const offering = offerings.find( + o => o.modelId === ctx.modelId && (ctx.maxPrice === undefined || combinedPrice(o) <= ctx.maxPrice), + ) + if (!offering) return null + const {page} = await listProviders(chain, 0n, 1n) + // We don't strictly need the row for manual mode, so synthesize the parts + // the caller cares about from the offerings call. + const provider = (page.find(p => p.owner === owner) ?? {owner}) as ProviderRow + return {provider, offering} +} + +function rank(candidates: CandidateProvider[], strategy: SelectionStrategy): CandidateProvider | null { + if (candidates.length === 0) return null + + if (strategy === 'cheapest') { + return [...candidates].sort((a, b) => Number(combinedPrice(a.offering) - combinedPrice(b.offering)))[0]! + } + + // top_rep_cheapest + const newcomers = candidates.filter(c => c.provider.totalJobs < MIN_TOTAL_JOBS) + const seasoned = candidates + .filter(c => c.provider.totalJobs >= MIN_TOTAL_JOBS) + .filter(c => successRate(c.provider) >= MIN_SUCCESS_RATE) + + if (seasoned.length > 0 && newcomers.length > 0 && Math.random() < EXPLORATION_SHARE) { + return newcomers[Math.floor(Math.random() * newcomers.length)]! + } + const pool = seasoned.length > 0 ? seasoned : candidates + return [...pool].sort((a, b) => Number(combinedPrice(a.offering) - combinedPrice(b.offering)))[0]! +} + +function successRate(p: ProviderRow): number { + return p.totalJobs === 0 ? 0 : p.successfulJobs / p.totalJobs +} diff --git a/src/users/processor.ts b/src/users/processor.ts new file mode 100644 index 0000000..a182cc5 --- /dev/null +++ b/src/users/processor.ts @@ -0,0 +1,51 @@ +import type { Job } from 'bullmq'; +import type { User } from '@prisma/client'; +import { BaseProcessor } from '../queue/processor'; +import { model, prisma } from '../db'; +import { creditWelcomeGrant } from '../accounts/ledger'; +import { logger } from '../logger'; + +const USER_FIELDS = ['email', 'emailVerified', 'role', 'stripeCustomerId', 'country'] as const; + +class UsersProcessor extends BaseProcessor { + constructor() { + super('user', USER_FIELDS); + } + + /** New user → create their Account (which in turn triggers the welcome grant + * if the user is already email-verified, e.g. via Google OAuth). */ + protected async onCreated(_job: Job, user: User): Promise { + await model.account.create({ + data: { + userId: user.id, + balanceXbzzWei: '0', + reservedXbzzWei: '0', + }, + }); + } + + /** + * Field-level handlers fire when a scalar column changes (computed by + * BaseProcessor's diff). We watch `emailVerified` so email-signup users who + * verify *after* their account row exists still get the welcome grant. + * Idempotent — `creditWelcomeGrant` keys on the account id. + */ + protected getFieldHandlers(_job: Job, entity: User): Record Promise> { + return { + emailVerified: async () => { + if (!entity.emailVerified) return; // only fire on false → true + const account = await prisma.account.findUnique({ + where: { userId: entity.id }, + select: { id: true }, + }); + if (!account) { + logger.warn({ userId: entity.id }, 'verified user has no account row yet — skipping grant'); + return; + } + await creditWelcomeGrant(account.id); + }, + }; + } +} + +export const usersProcessor = new UsersProcessor(); diff --git a/src/users/routes.ts b/src/users/routes.ts new file mode 100644 index 0000000..a3ce1b0 --- /dev/null +++ b/src/users/routes.ts @@ -0,0 +1,21 @@ +import Elysia from 'elysia'; +import { prisma } from '../db'; +import { jwtGuard } from '../auth/jwt'; + +export const usersRoutes = new Elysia({ prefix: '/users' }) + .use(jwtGuard) + .get('/me', async ({ user }) => { + const row = await prisma.user.findUniqueOrThrow({ + where: { id: user.userId }, + select: { + id: true, + email: true, + role: true, + emailVerified: true, + authProvider: true, + country: true, + createdAt: true, + }, + }); + return row; + }); diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 0000000..2f6bc91 --- /dev/null +++ b/src/worker.ts @@ -0,0 +1,18 @@ +import { startWorkers, stopWorkers } from './queue/startup'; +import { startClaimWatcher } from './chain/claimWatcher'; +import { startBalanceMonitor } from './chain/balanceMonitor'; +import { logger } from './logger'; + +startWorkers(); +startClaimWatcher(); +startBalanceMonitor(); + +logger.info('worker process up'); + +const shutdown = async (sig: string) => { + logger.info({ sig }, 'shutting down'); + await stopWorkers(); + process.exit(0); +}; +process.on('SIGINT', () => shutdown('SIGINT')); +process.on('SIGTERM', () => shutdown('SIGTERM')); diff --git a/test/auth.e2e-spec.ts b/test/auth.e2e-spec.ts new file mode 100644 index 0000000..8f38b60 --- /dev/null +++ b/test/auth.e2e-spec.ts @@ -0,0 +1,6 @@ +import { describeAuth } from './auth'; +import './setup'; + +// `describeAuth()` registers its own `describe(...)` block. setup.ts's +// top-level beforeAll/afterAll fire once for the file. +describeAuth(); diff --git a/test/auth.ts b/test/auth.ts new file mode 100644 index 0000000..ed7943e --- /dev/null +++ b/test/auth.ts @@ -0,0 +1,204 @@ +/** + * Sequential auth + profile + API key flow against the running hosted-gateway + * stack. Mirrors cipherdolls/core/test/auth.ts shape: one `describeAuth()` that + * exercises each persona end-to-end, with state shared across `it()` blocks + * via module-level `let` bindings. + * + * What this covers: + * - Email + password signup (with verification token echoed back in dev/test). + * - Email verification → account activation → 50 xBZZ welcome grant credited. + * - JWT-authed /users/me and /accounts/me reads. + * - API key creation, listing, JWT-or-key auth interchange, deletion. + * - /api-keys returns the persona's keys only. + * - Login round-trip returns a fresh JWT for the same user. + */ +import { describe, it, expect } from 'bun:test'; +import { + api, + get, + post, + del, + auth, + signupEmail, + verifyEmail, + loginEmail, + createApiKey, + waitForQueuesEmpty, +} from './helpers'; + +const WELCOME_XBZZ_WEI = (50n * 10n ** 18n).toString(); + +export function describeAuth(): void { + describe('auth + profile + api-keys (e2e)', () => { + + /* ── Alice: full signup → verify → welcome grant ────────────── */ + + it('signs up Alice via email/password', async () => { + const email = `alice-${Date.now()}@hosted.test`; + const password = 'correct-horse-battery-staple'; + const { jwt, userId, verificationToken } = await signupEmail(email, password); + expect(jwt).toBeTruthy(); + expect(userId).toBeTruthy(); + expect(verificationToken).toBeTruthy(); + auth.alice = { email, password, jwt, userId }; + (auth.alice as any).verificationToken = verificationToken; + }); + + it('Alice can read /users/me with the issued JWT (email unverified)', async () => { + const { status, body } = await get('/users/me', auth.alice.jwt!); + expect(status).toBe(200); + expect(body).toMatchObject({ + id: auth.alice.userId, + email: auth.alice.email, + role: 'USER', + emailVerified: false, + authProvider: 'email', + }); + }); + + it('Alice has no welcome grant yet (account pending email verify)', async () => { + await waitForQueuesEmpty(); + const { status, body } = await get('/accounts/me', auth.alice.jwt!); + expect(status).toBe(200); + expect(body.balanceXbzzWei).toBe('0'); + expect(body.reservedXbzzWei).toBe('0'); + }); + + it('Alice verifies her email → balance credited with 50 xBZZ', async () => { + const token = (auth.alice as any).verificationToken as string; + await verifyEmail(token); + await waitForQueuesEmpty(); + + const { status, body } = await get('/accounts/me', auth.alice.jwt!); + expect(status).toBe(200); + expect(body.balanceXbzzWei).toBe(WELCOME_XBZZ_WEI); + expect(body.reservedXbzzWei).toBe('0'); + expect(body.availableXbzzWei).toBe(WELCOME_XBZZ_WEI); + + const me = await get('/users/me', auth.alice.jwt!); + expect(me.body.emailVerified).toBe(true); + }); + + it('Alice can list her ledger and see the welcomeGrant row', async () => { + const { status, body } = await get('/accounts/me/ledger', auth.alice.jwt!); + expect(status).toBe(200); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBe(1); + expect(body[0].kind).toBe('welcomeGrant'); + expect(body[0].deltaXbzzWei).toBe(WELCOME_XBZZ_WEI); + }); + + /* ── API keys ─────────────────────────────────────────────── */ + + it('Alice creates an API key', async () => { + const { id, key } = await createApiKey(auth.alice.jwt!, 'alice-key'); + expect(id).toBeTruthy(); + expect(key.startsWith('t4t-live-')).toBe(true); + auth.alice.apiKey = key; + auth.alice.apiKeyId = id; + }); + + it('Alice can read /users/me with her API key (no JWT)', async () => { + const { status, body } = await get('/users/me', auth.alice.apiKey); + expect(status).toBe(200); + expect(body.id).toBe(auth.alice.userId); + }); + + it('Alice lists /api-keys and sees only her key', async () => { + const { status, body } = await get('/api-keys', auth.alice.jwt!); + expect(status).toBe(200); + expect(body.length).toBe(1); + expect(body[0].id).toBe(auth.alice.apiKeyId); + expect(body[0].name).toBe('alice-key'); + // Secret value is never echoed after creation. + expect(body[0].key).toBeUndefined(); + }); + + /* ── Bob: isolation ───────────────────────────────────────── */ + + it('Bob signs up, verifies, and gets his own welcome grant', async () => { + const email = `bob-${Date.now()}@hosted.test`; + const password = 'tr0ub4dor&3'; + const signup = await signupEmail(email, password); + await verifyEmail(signup.verificationToken); + await waitForQueuesEmpty(); + const { id: apiKeyId, key: apiKey } = await createApiKey(signup.jwt, 'bob-key'); + auth.bob = { + email, + password, + jwt: signup.jwt, + userId: signup.userId, + apiKey, + apiKeyId, + }; + + const accountRes = await get('/accounts/me', signup.jwt); + expect(accountRes.body.balanceXbzzWei).toBe(WELCOME_XBZZ_WEI); + }); + + it("Bob cannot see Alice's API keys", async () => { + const { status, body } = await get('/api-keys', auth.bob.jwt!); + expect(status).toBe(200); + expect(body.length).toBe(1); + expect(body[0].id).toBe(auth.bob.apiKeyId); + }); + + it("Bob cannot delete Alice's API key", async () => { + const { status } = await del(`/api-keys/${auth.alice.apiKeyId}`, auth.bob.jwt!); + expect(status).toBe(404); + }); + + /* ── Login round-trip ─────────────────────────────────────── */ + + it('Alice logs in with email + password and gets a fresh JWT', async () => { + const { jwt, userId } = await loginEmail(auth.alice.email!, auth.alice.password!); + expect(userId).toBe(auth.alice.userId); + // The new JWT still resolves to the same user. + const { status, body } = await get('/users/me', jwt); + expect(status).toBe(200); + expect(body.id).toBe(auth.alice.userId); + }); + + it('login with wrong password is rejected', async () => { + const { status } = await post('/auth/email/login', { + email: auth.alice.email, + password: 'WRONG', + }); + expect(status).toBe(401); + }); + + it('signup with an existing email is rejected (409)', async () => { + const { status } = await post('/auth/email/signup', { + email: auth.alice.email, + password: 'another-password-123', + }); + expect(status).toBe(409); + }); + + /* ── Unauthed access ──────────────────────────────────────── */ + + it('rejects /users/me with no token (401)', async () => { + const { status } = await api('GET', '/users/me'); + expect(status).toBe(401); + }); + + it('rejects /accounts/me with garbage token (401)', async () => { + const { status } = await get('/accounts/me', 'not-a-real-token'); + expect(status).toBe(401); + }); + + /* ── Key deletion ─────────────────────────────────────────── */ + + it('Alice can delete her API key, and it stops working', async () => { + const del1 = await del(`/api-keys/${auth.alice.apiKeyId}`, auth.alice.jwt!); + expect(del1.status).toBe(200); + + const meAfter = await get('/users/me', auth.alice.apiKey); + expect(meAfter.status).toBe(401); + + // JWT still works — we only revoked the key. + const meJwt = await get('/users/me', auth.alice.jwt!); + expect(meJwt.status).toBe(200); + }); + }); +} diff --git a/test/fixtures/fundOperator.ts b/test/fixtures/fundOperator.ts new file mode 100644 index 0000000..2dd8f4d --- /dev/null +++ b/test/fixtures/fundOperator.ts @@ -0,0 +1,122 @@ +/** + * Test bootstrap: gives the operator wallet (and optionally the test provider) + * xDAI + xBZZ on the anvil fork. The contracts live on Gnosis mainnet at the + * addresses configured via env (managed by the t4t repo), so we don't deploy + * anything — we just fund the test wallets against the existing tokens. + * + * Two cheatcodes: + * - `anvil_setBalance(addr, wei)` seeds native xDAI + * - `anvil_impersonateAccount(whale)` lets us send `xBZZ.transfer` as the whale + * + * The whale address is configurable via `XBZZ_WHALE` env (default below is a + * known high-balance holder on Gnosis Chain). Refresh from Gnosisscan if the + * default ever runs dry. + */ +import { + createPublicClient, + createTestClient, + createWalletClient, + http, + parseEther, + type Address, + type Hex, +} from 'viem'; +import { gnosis } from 'viem/chains'; +import { privateKeyToAccount } from 'viem/accounts'; + +const ERC20_TRANSFER_ABI = [ + { + type: 'function', + name: 'transfer', + stateMutability: 'nonpayable', + inputs: [ + { name: 'to', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ type: 'bool' }], + }, + { + type: 'function', + name: 'balanceOf', + stateMutability: 'view', + inputs: [{ name: 'a', type: 'address' }], + outputs: [{ type: 'uint256' }], + }, +] as const; + +export interface FundConfig { + rpcUrl: string; + xbzzAddress: Address; + whale: Address; + recipients: Array<{ address: Address; xdai: bigint; xbzz: bigint }>; +} + +export async function fundOperator(cfg: FundConfig): Promise { + const transport = http(cfg.rpcUrl); + const pub = createPublicClient({ chain: gnosis, transport }); + const test = createTestClient({ chain: gnosis, transport, mode: 'anvil' }); + + // xDAI via setBalance — instant, no impersonation needed. + for (const r of cfg.recipients) { + await test.setBalance({ address: r.address, value: r.xdai }); + } + + // xBZZ via impersonation + transfer. The whale must have ≥ Σ xbzz on chain. + await test.impersonateAccount({ address: cfg.whale }); + // Make sure the whale itself has xDAI to pay gas on the transfer. + await test.setBalance({ address: cfg.whale, value: parseEther('1') }); + + const whaleClient = createWalletClient({ + chain: gnosis, + transport, + account: cfg.whale, + }); + for (const r of cfg.recipients) { + const hash = await whaleClient.writeContract({ + address: cfg.xbzzAddress, + abi: ERC20_TRANSFER_ABI, + functionName: 'transfer', + args: [r.address, r.xbzz], + }); + await pub.waitForTransactionReceipt({ hash }); + } + await test.stopImpersonatingAccount({ address: cfg.whale }); + + // Sanity-check the funding landed. + for (const r of cfg.recipients) { + const bal = (await pub.readContract({ + address: cfg.xbzzAddress, + abi: ERC20_TRANSFER_ABI, + functionName: 'balanceOf', + args: [r.address], + })) as bigint; + if (bal < r.xbzz) { + throw new Error(`funding failed: ${r.address} expected ${r.xbzz}, got ${bal}`); + } + } +} + +/** + * Convenience: derive the operator address from `OPERATOR_PRIVATE_KEY` and + * apply a standard funding amount (1k xBZZ + 1 xDAI). + */ +export async function fundOperatorFromEnv(extra: Address[] = []): Promise { + const operator = privateKeyToAccount(process.env.OPERATOR_PRIVATE_KEY as Hex).address; + const recipients = [ + { address: operator, xdai: parseEther('1'), xbzz: 1000n * 10n ** 18n }, + ...extra.map((address) => ({ + address, + xdai: parseEther('1'), + xbzz: 1000n * 10n ** 18n, + })), + ]; + await fundOperator({ + rpcUrl: process.env.GNOSIS_RPC_URL ?? 'http://localhost:8545', + xbzzAddress: (process.env.XBZZ_ADDRESS ?? '0xdBF3Ea6F5beE45c02255B2c26a16F300502F68da') as Address, + // The "t4t wallet" on Gnosis — a known funded address that's used as the + // impersonation source on the fork. Holds xBZZ + xDAI on mainnet, so on + // the fork we anvil_impersonateAccount it and transfer to the operator. + whale: (process.env.XBZZ_WHALE ?? '0xaCfE00BeA3f8acC8e500c7Ea6421902014778a85') as Address, + recipients, + }); +} diff --git a/test/gateway.e2e-spec.ts b/test/gateway.e2e-spec.ts new file mode 100644 index 0000000..5617f4f --- /dev/null +++ b/test/gateway.e2e-spec.ts @@ -0,0 +1,4 @@ +import { describeGateway } from './gateway'; +import './setup'; + +describeGateway(); diff --git a/test/gateway.ts b/test/gateway.ts new file mode 100644 index 0000000..e2a1b8b --- /dev/null +++ b/test/gateway.ts @@ -0,0 +1,96 @@ +/** + * Chat-completion end-to-end. This is the heaviest spec — requires the full + * gateway profile (anvil fork + Bee + a registered t4t-provider + mock-ollama) + * AND a funded operator wallet on the fork. + * + * If the upstream prerequisites (provider registration, xBZZ funding) haven't + * been set up the test surfaces the actual failure mode — `no provider matches + * model`, 503 on gateway-context boot, etc. — rather than green-washing. + */ +import { describe, it, expect } from 'bun:test'; +import { + api, + get, + post, + auth, + signupEmail, + verifyEmail, + createApiKey, + waitForQueuesEmpty, +} from './helpers'; + +export function describeGateway(): void { + describe('chat completions (e2e)', () => { + + it('signs Carol up + verifies + mints an API key', async () => { + const email = `carol-${Date.now()}@hosted.test`; + const signup = await signupEmail(email, 'a-strong-password-1234'); + await verifyEmail(signup.verificationToken); + await waitForQueuesEmpty(); + const { id, key } = await createApiKey(signup.jwt, 'carol-key'); + auth.carol = { + email, + password: 'a-strong-password-1234', + jwt: signup.jwt, + userId: signup.userId, + apiKey: key, + apiKeyId: id, + }; + }); + + it('GET /v1/models lists the catalogue (may be empty until a provider registers)', async () => { + const { status, body } = await get('/v1/models', auth.carol.apiKey); + // Auth alone gets us a 200; whether the catalogue is empty depends on + // whether the t4t-provider container had time to register before this + // request fired. + expect(status).toBe(200); + expect(body).toHaveProperty('object', 'list'); + expect(Array.isArray(body.data)).toBe(true); + }); + + it('POST /v1/chat/completions returns a 200 with assistant content (mock-ollama)', async () => { + // The mock-ollama always returns "pong (mock-ollama)" regardless of + // input, so this assertion only verifies the route + on-chain settle + // path, not LLM quality. + const { status, body } = await post( + '/v1/chat/completions', + { + model: 'qwen2.5:0.5b', + messages: [{ role: 'user', content: 'ping' }], + max_tokens: 32, + }, + auth.carol.apiKey, + ); + + if (status !== 200) { + // Surface the upstream error so we know what to fix: provider not + // registered, gateway context boot failed, insufficient balance, etc. + console.error(`chat completion failed: status=${status} body=${JSON.stringify(body)}`); + } + + expect(status).toBe(200); + expect(body).toHaveProperty('choices'); + expect(body.choices[0]?.message?.content).toMatch(/pong/); + }); + + it("debits Carol's balance after the JobClaimed watcher settles", async () => { + // The on-chain claimJob fires from the provider after delivering. Our + // worker's claimWatcher tails JobClaimed events and runs settleXbzz. + // Poll for up to 30s. + const deadline = Date.now() + 30_000; + let balance = 50n * 10n ** 18n; + while (Date.now() < deadline) { + const { body } = await get('/accounts/me', auth.carol.jwt!); + balance = BigInt(body.balanceXbzzWei); + if (balance < 50n * 10n ** 18n) break; + await new Promise((r) => setTimeout(r, 1000)); + } + expect(balance).toBeLessThan(50n * 10n ** 18n); + + const ledger = await get('/accounts/me/ledger', auth.carol.jwt!); + const settle = ledger.body.find((e: any) => e.kind === 'settle'); + expect(settle).toBeDefined(); + expect(BigInt(settle.deltaXbzzWei)).toBeLessThan(0n); + }); + }); +} diff --git a/test/health.e2e-spec.ts b/test/health.e2e-spec.ts new file mode 100644 index 0000000..f50dc81 --- /dev/null +++ b/test/health.e2e-spec.ts @@ -0,0 +1,11 @@ +import { describe, test, expect } from 'bun:test'; +import { api } from './helpers'; + +describe('health', () => { + test('GET /health returns ok', async () => { + const res = await api('/health'); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + }); +}); diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..55fbba5 --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,144 @@ +/** + * Shared E2E helpers. Tests run inside the dockerized stack — the api/worker + * are reachable as service names on the compose network. + * + * Pattern lifted from cipherdolls/core/test/helpers.ts: a single `api(...)` + * wrapper around fetch, a `waitForQueuesEmpty()` poller for the + * model.x.create → BullMQ → processor pipeline, and persona-style auth + * records keyed in a shared `auth` table. + */ +import IORedis from 'ioredis'; + +export const BASE_URL = process.env.HOSTED_GATEWAY_URL ?? 'http://localhost:8080'; +const REDIS_HOST = process.env.REDIS_HOST ?? 'localhost'; +const REDIS_PORT = parseInt(process.env.REDIS_PORT ?? '6379'); + +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + +export interface ApiResult { + status: number; + body: T; +} + +/** + * Issue an HTTP request to the hosted-gateway api. Accepts either a JWT or an + * API key (bare uuid OR the `t4t-live-` form) as `auth`. + */ +export async function api( + method: HttpMethod, + path: string, + auth?: string, + body?: any, +): Promise> { + const headers: Record = { 'Content-Type': 'application/json' }; + if (auth) headers.Authorization = `Bearer ${auth}`; + const res = await fetch(`${BASE_URL}${path}`, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let parsed: any = text; + try { parsed = JSON.parse(text); } catch { /* keep raw */ } + return { status: res.status, body: parsed as T }; +} + +export const get = (path: string, auth?: string) => api('GET', path, auth); +export const post = (path: string, body: any, auth?: string) => api('POST', path, auth, body); +export const del = (path: string, auth?: string) => api('DELETE', path, auth); + +/** + * Wait until all BullMQ queues are empty AND stay empty for `settleMs` — + * defends against cascades where one processor enqueues the next (eg + * user.created → account.created → ledger welcomeGrant). + */ +export async function waitForQueuesEmpty( + timeoutMs = 15_000, + settleMs = 500, +): Promise { + const redis = new IORedis(REDIS_PORT, REDIS_HOST, { maxRetriesPerRequest: null }); + const interval = 100; + let elapsed = 0; + let emptyFor = 0; + try { + while (elapsed < timeoutMs) { + const queueKeys = [ + ...(await redis.keys('bull:*:waiting')), + ...(await redis.keys('bull:*:active')), + ...(await redis.keys('bull:*:delayed')), + ]; + let total = 0; + for (const key of queueKeys) { + const type = await redis.type(key); + if (type === 'list') total += await redis.llen(key); + else if (type === 'zset') total += await redis.zcard(key); + } + if (total === 0) { + emptyFor += interval; + if (emptyFor >= settleMs) return; + } else { + emptyFor = 0; + } + await new Promise((r) => setTimeout(r, interval)); + elapsed += interval; + } + throw new Error(`queues not empty after ${timeoutMs}ms`); + } finally { + redis.disconnect(); + } +} + +/* ──────────────────────────────────────────────────────────────── + Auth flow helpers — mirror cipherdolls' signIn / createApiKey shape, + but for email/password (Phase 1 doesn't wire real Google OAuth in tests). + ──────────────────────────────────────────────────────────────── */ + +export interface AuthRecord { + email: string; + password: string; + jwt: string; + userId: string; + apiKey: string; + apiKeyId: string; +} + +/** Persona registry — populated by `describe*()` factories and read by + * downstream specs. New personas added as the test suite grows. */ +export const auth: Record> = { + alice: {}, + bob: {}, + carol: {}, + dave: {}, +}; + +export async function signupEmail( + email: string, + password: string, +): Promise<{ jwt: string; userId: string; verificationToken: string }> { + const { status, body } = await post('/auth/email/signup', { email, password }); + if (status !== 200) throw new Error(`signup ${email} failed: ${status} ${JSON.stringify(body)}`); + return { jwt: body.token, userId: body.user.id, verificationToken: body.verificationToken }; +} + +export async function verifyEmail(token: string): Promise { + const { status, body } = await post('/auth/email/verify', { token }); + if (status !== 200) throw new Error(`verify failed: ${status} ${JSON.stringify(body)}`); +} + +export async function loginEmail( + email: string, + password: string, +): Promise<{ jwt: string; userId: string }> { + const { status, body } = await post('/auth/email/login', { email, password }); + if (status !== 200) throw new Error(`login ${email} failed: ${status} ${JSON.stringify(body)}`); + return { jwt: body.token, userId: body.user.id }; +} + +export async function createApiKey( + jwt: string, + name: string, +): Promise<{ id: string; key: string }> { + const { status, body } = await post('/api-keys', { name }, jwt); + if (status !== 200) throw new Error(`createApiKey failed: ${status} ${JSON.stringify(body)}`); + return { id: body.id, key: body.key }; +} diff --git a/test/models.e2e-spec.ts b/test/models.e2e-spec.ts new file mode 100644 index 0000000..d0826c2 --- /dev/null +++ b/test/models.e2e-spec.ts @@ -0,0 +1,4 @@ +import { describeModels } from './models'; +import './setup'; + +describeModels(); diff --git a/test/models.ts b/test/models.ts new file mode 100644 index 0000000..ede1428 --- /dev/null +++ b/test/models.ts @@ -0,0 +1,62 @@ +/** + * Lightweight /v1/models smoke test against the anvil-forked Gnosis chain. + * + * Validates: + * - api boots and `getGatewayContext()` succeeds (operator wallet, Bee, PSS) + * - `ModelDiscovery.list()` runs against the live ProviderRegistry on the fork + * - the route returns the OpenAI `list` shape + * + * No chat completion, no on-chain writes, no t4t-provider container required. + * If the fork has any mainnet provider that heartbeated within the last 10 + * min before the fork, the catalogue should be non-empty. + */ +import { describe, it, expect } from 'bun:test'; +import { + auth, + signupEmail, + verifyEmail, + createApiKey, + get, + waitForQueuesEmpty, +} from './helpers'; + +export function describeModels(): void { + describe('/v1/models (e2e against forked Gnosis)', () => { + it('signs up Dave + mints an API key', async () => { + const email = `dave-${Date.now()}@hosted.test`; + const signup = await signupEmail(email, 'a-strong-password-1234'); + await verifyEmail(signup.verificationToken); + await waitForQueuesEmpty(); + const { id, key } = await createApiKey(signup.jwt, 'dave-key'); + auth.dave = { + email, + password: 'a-strong-password-1234', + jwt: signup.jwt, + userId: signup.userId, + apiKey: key, + apiKeyId: id, + }; + }); + + it('GET /v1/models returns the OpenAI list shape', async () => { + const { status, body } = await get('/v1/models', auth.dave.apiKey); + if (status !== 200) { + console.error('models endpoint failed:', JSON.stringify(body)); + } + expect(status).toBe(200); + expect(body).toHaveProperty('object', 'list'); + expect(Array.isArray(body.data)).toBe(true); + }); + + it('catalogue contains at least one model (mainnet has live providers)', async () => { + const { body } = await get('/v1/models', auth.dave.apiKey); + // Phase 1: tolerate an empty catalogue (no live mainnet providers + // recently heartbeated). Log the count so the run surfaces it. + console.log(`/v1/models returned ${body.data.length} models`); + if (body.data.length > 0) { + expect(body.data[0]).toHaveProperty('id'); + expect(body.data[0]).toHaveProperty('object', 'model'); + } + }); + }); +} diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..d715b64 --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,66 @@ +/** + * E2E test bootstrap. Runs inside the `bun` test container (see + * devops/local/docker-compose-test.yaml) against the dockerized stack: + * + * - `prisma migrate reset` so the schema matches HEAD. + * - `redis-cli FLUSHALL` clears queues + locks + nonce. + * - Fund the operator wallet on the anvil fork with xBZZ + xDAI via + * `fundOperatorFromEnv()` (anvil_setBalance + xBZZ whale impersonation). + * The contracts themselves already exist on the fork — they're managed + * by the t4t repo and live on Gnosis mainnet; the fork inherits them. + * - `evm_snapshot` so each spec can revert to a clean fork. + */ +import { $ } from 'bun'; +import { redisConnection } from '../src/queue/connection'; +import { fundOperatorFromEnv } from './fixtures/fundOperator'; + +let snapshotId: string | null = null; + +export async function setBeforeAll(): Promise { + // The compose `prisma` service applied migrations on stack-up. Postgres uses + // tmpfs in the test stack so each compose run starts fresh — within one + // run we just need to clear Redis (BullMQ jobs + the operator nonce). + await redisConnection.flushall(); + + // Fund the operator wallet on the fork. Only needed for specs that settle a + // chat completion on-chain (gateway / concurrency / cancellation). Auth / + // accounts specs don't touch chain, so a funding failure is non-fatal here — + // the chain specs will surface a clearer error later if they actually need it. + try { + await fundOperatorFromEnv(); + } catch (err: any) { + console.warn(`[setup] fundOperatorFromEnv failed (non-fatal for non-chain specs): ${err.message}`); + } + + snapshotId = await anvilSnapshot().catch(() => null); +} + +export async function setAfterAll(): Promise { + if (snapshotId) await anvilRevert(snapshotId).catch(() => undefined); + await redisConnection.quit(); +} + +async function anvilSnapshot(): Promise { + const res = await fetch(process.env.GNOSIS_RPC_URL ?? 'http://localhost:8545', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'evm_snapshot', params: [], id: 1 }), + }); + const json = (await res.json()) as { result?: string }; + if (!json.result) throw new Error('evm_snapshot returned no id'); + return json.result; +} + +async function anvilRevert(id: string): Promise { + await fetch(process.env.GNOSIS_RPC_URL ?? 'http://localhost:8545', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'evm_revert', params: [id], id: 1 }), + }); +} + +// Bun auto-invokes the top-level `beforeAll` / `afterAll` from the preloaded +// setup file when called as `--preload`. +import { afterAll, beforeAll } from 'bun:test'; +beforeAll(setBeforeAll); +afterAll(setAfterAll); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4bcb8da --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "types": ["bun-types"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "outDir": "./dist", + "rootDir": "./src", + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}