init
This commit is contained in:
commit
c54e888458
67 files changed
+5933
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.git
|
||||||
@@ -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=
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
bun.lock.bak
|
||||||
+23
@@ -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"]
|
||||||
@@ -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.<entity> 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
|
||||||
|
```
|
||||||
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[test]
|
||||||
|
timeout = 120000
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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:<gatewayJobId>` ledger key.
|
||||||
|
*/
|
||||||
|
export async function settleXbzz(
|
||||||
|
userId: string,
|
||||||
|
gatewayJobId: string,
|
||||||
|
estimateXbzzWei: bigint,
|
||||||
|
actualXbzzWei: bigint,
|
||||||
|
): Promise<void> {
|
||||||
|
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:<gatewayJobId>` — 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<void> {
|
||||||
|
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()) } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<Account> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
@@ -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() }));
|
||||||
|
});
|
||||||
+42
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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() }) },
|
||||||
|
);
|
||||||
@@ -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<GoogleProfile> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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-<uuid>` 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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<void> {
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
// 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<void> {
|
||||||
|
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<Hex> {
|
||||||
|
return withLock(OPERATOR_LOCK, () => cancelJobRaw(c, jobId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeoutJob(c: ChainClient, jobId: Hex): Promise<Hex> {
|
||||||
|
return withLock(OPERATOR_LOCK, () => timeoutJobRaw(c, jobId));
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// `model.<entity>.{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),
|
||||||
|
};
|
||||||
+85
@@ -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<typeof EnvSchema> & {
|
||||||
|
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();
|
||||||
@@ -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<typeof setTimeout>;
|
||||||
|
timeoutTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Hex, PendingSlot>;
|
||||||
|
failureTimers: Map<Hex, FailureSlot>;
|
||||||
|
jobMeta: Map<Hex, JobMetaSlot>;
|
||||||
|
clearFailureTimers: (routing: Hex) => void;
|
||||||
|
rejectAndCleanup: (routing: Hex, err: Error) => void;
|
||||||
|
scheduleAckTimeout: (routing: Hex, slot: FailureSlot) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bootPromise: Promise<GatewayContext> | null = null;
|
||||||
|
|
||||||
|
export function getGatewayContext(): Promise<GatewayContext> {
|
||||||
|
if (!bootPromise) bootPromise = bootGatewayContext();
|
||||||
|
return bootPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootGatewayContext(): Promise<GatewayContext> {
|
||||||
|
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<Hex, PendingSlot>();
|
||||||
|
const failureTimers = new Map<Hex, FailureSlot>();
|
||||||
|
const jobMeta = new Map<Hex, JobMetaSlot>();
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<ResponsePayload>(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<string> {
|
||||||
|
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<Hex> {
|
||||||
|
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<string, unknown> = {},
|
||||||
|
): Promise<void> {
|
||||||
|
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/, '')));
|
||||||
|
}
|
||||||
@@ -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<HandleChatResult> {
|
||||||
|
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<OpenAIChatResponse>((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<JobNotifyBody>(
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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<ModelOffering, 'inputPricePerMillionTokens' | 'outputPricePerMillionTokens' | 'maxContextTokens'>,
|
||||||
|
): { 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;
|
||||||
|
}
|
||||||
@@ -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<number | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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<T>(
|
||||||
|
key: string,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
ttl = DEFAULT_TTL,
|
||||||
|
): Promise<T> {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T extends Record<string, any>> {
|
||||||
|
constructor(
|
||||||
|
protected readonly entityName: string,
|
||||||
|
protected readonly scalarFields: readonly string[],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async process(job: Job): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {}
|
||||||
|
protected async onUpdated(
|
||||||
|
_job: Job,
|
||||||
|
_entity: T,
|
||||||
|
_original: T,
|
||||||
|
_updatedFields: string[],
|
||||||
|
): Promise<void> {}
|
||||||
|
protected async onDeleted(_job: Job, _entity: T): Promise<void> {}
|
||||||
|
|
||||||
|
protected getFieldHandlers(_job: Job, _entity: T): Record<string, () => Promise<void>> {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, Queue>();
|
||||||
|
const workers = new Map<string, Worker>();
|
||||||
|
|
||||||
|
export function getQueue<T = any>(name: string): Queue<T> {
|
||||||
|
if (!queues.has(name)) {
|
||||||
|
queues.set(name, new Queue(name, { connection: queueConnection }));
|
||||||
|
}
|
||||||
|
return queues.get(name)! as Queue<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerWorker<T = any>(
|
||||||
|
name: string,
|
||||||
|
handler: Processor<T>,
|
||||||
|
opts?: { concurrency?: number },
|
||||||
|
): Worker<T> {
|
||||||
|
const worker = new Worker<T>(name, handler, {
|
||||||
|
connection: queueConnection,
|
||||||
|
concurrency: opts?.concurrency ?? defaultOpts.concurrency,
|
||||||
|
autorun: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.on('failed', (job: Job<T> | undefined, err: Error) => {
|
||||||
|
logger.error({ queue: name, jobId: job?.id, jobName: job?.name, err: err.message }, 'job failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.on('completed', (job: Job<T>) => {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
if (this.available > 0) {
|
||||||
|
this.available -= 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise<void>((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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T>(
|
||||||
|
queueName: string,
|
||||||
|
prismaDelegate: {
|
||||||
|
create: (args: any) => Promise<T>;
|
||||||
|
update: (args: any) => Promise<T>;
|
||||||
|
delete: (args: any) => Promise<T>;
|
||||||
|
findUnique: (args: any) => Promise<T | null>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const queue = getQueue(queueName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
async create(args: any): Promise<T> {
|
||||||
|
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<T> {
|
||||||
|
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<T> {
|
||||||
|
const entity = await prismaDelegate.findUnique({ where: args.where });
|
||||||
|
await queue.add('deleted', { [queueName]: serializeBigInts(entity) });
|
||||||
|
return prismaDelegate.delete(args);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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<number | null> | null = null
|
||||||
|
|
||||||
|
export async function getBzzUsd(logger?: Logger): Promise<number | null> {
|
||||||
|
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<string, {usd?: number}>
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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<Hex> {
|
||||||
|
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<Hex> {
|
||||||
|
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<Hex> {
|
||||||
|
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<Hex> {
|
||||||
|
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<Hex> {
|
||||||
|
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<bigint> {
|
||||||
|
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<bigint> {
|
||||||
|
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<ProviderRow> {
|
||||||
|
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<ModelOffering[]> {
|
||||||
|
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<Hex> {
|
||||||
|
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<Hex> {
|
||||||
|
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<Hex> {
|
||||||
|
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<Hex> {
|
||||||
|
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<void> {
|
||||||
|
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`})
|
||||||
|
}
|
||||||
@@ -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<Uint8Array>
|
||||||
|
decrypt(ciphertext: Uint8Array): Promise<Uint8Array>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Uint8Array> {
|
||||||
|
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<Uint8Array> {
|
||||||
|
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<Uint8Array> {
|
||||||
|
return plaintext
|
||||||
|
}
|
||||||
|
async decrypt(ciphertext: Uint8Array): Promise<Uint8Array> {
|
||||||
|
return ciphertext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jsonEncrypt(cipher: PayloadCipher, recipient: Hex, value: unknown): Promise<Uint8Array> {
|
||||||
|
return cipher.encrypt(new TextEncoder().encode(JSON.stringify(value)), recipient)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function jsonDecrypt<T>(cipher: PayloadCipher, ct: Uint8Array): Promise<T> {
|
||||||
|
const pt = await cipher.decrypt(ct)
|
||||||
|
return JSON.parse(new TextDecoder().decode(pt)) as T
|
||||||
|
}
|
||||||
@@ -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<string, unknown>
|
||||||
|
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<TBody> {
|
||||||
|
from: Hex
|
||||||
|
to: Hex
|
||||||
|
type: EnvelopeType
|
||||||
|
body: TBody
|
||||||
|
ts?: number
|
||||||
|
nonce?: Hex
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signEnvelope<TBody>(
|
||||||
|
args: BuildEnvelopeArgs<TBody>,
|
||||||
|
signMessage: SignMessage,
|
||||||
|
): Promise<Envelope<TBody>> {
|
||||||
|
const unsigned: UnsignedEnvelope<TBody> = {
|
||||||
|
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<boolean> {
|
||||||
|
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<TBody = unknown>(bytes: Uint8Array | string): Envelope<TBody> {
|
||||||
|
const text = typeof bytes === 'string' ? bytes : new TextDecoder().decode(bytes)
|
||||||
|
return JSON.parse(text) as Envelope<TBody>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string>()
|
||||||
|
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<Envelope, 'from' | 'nonce'>): string {
|
||||||
|
return `${env.from.toLowerCase()}:${env.nonce.toLowerCase()}`
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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<bigint> {
|
||||||
|
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<T extends BatchLike>(
|
||||||
|
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<T extends BatchLike>(
|
||||||
|
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<BatchLike[]> {
|
||||||
|
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<ManagedStamp> {
|
||||||
|
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<bigint> {
|
||||||
|
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<void> {
|
||||||
|
await bee.diluteBatch(batchId, newDepth)
|
||||||
|
}
|
||||||
@@ -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<T>(opts: SwarmClientOpts, fn: () => Promise<T>): Promise<T> {
|
||||||
|
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<string> {
|
||||||
|
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<Uint8Array> {
|
||||||
|
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<string | null> {
|
||||||
|
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<void>
|
||||||
|
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<void> {
|
||||||
|
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')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TBody = unknown> {
|
||||||
|
v: typeof PROTOCOL_VERSION
|
||||||
|
type: EnvelopeType
|
||||||
|
from: Hex
|
||||||
|
to: Hex
|
||||||
|
ts: number
|
||||||
|
nonce: Hex
|
||||||
|
body: TBody
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Envelope<TBody = unknown> extends UnsignedEnvelope<TBody> {
|
||||||
|
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<Hex>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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<T> {
|
||||||
|
expiresAt: number
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ModelDiscovery {
|
||||||
|
private modelsCache: CacheEntry<ModelSummary[]> | null = null
|
||||||
|
private providersCache: CacheEntry<ProviderListing[]> | null = null
|
||||||
|
|
||||||
|
constructor(private readonly deps: DiscoverDeps) {}
|
||||||
|
|
||||||
|
async list(): Promise<ModelSummary[]> {
|
||||||
|
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<ProviderListing[]> {
|
||||||
|
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<string, {inputPrices: bigint[]; outputPrices: bigint[]; slowest: number; count: number}>()
|
||||||
|
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<string, {inputPrices: bigint[]; outputPrices: bigint[]; slowest: number; count: number}>,
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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<CandidateProvider | null> {
|
||||||
|
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<CandidateProvider | null> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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<User> {
|
||||||
|
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<void> {
|
||||||
|
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<string, () => Promise<void>> {
|
||||||
|
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();
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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'));
|
||||||
@@ -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();
|
||||||
+204
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Vendored
+122
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { describeGateway } from './gateway';
|
||||||
|
import './setup';
|
||||||
|
|
||||||
|
describeGateway();
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+144
@@ -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<T = any> {
|
||||||
|
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-<uuid>` form) as `auth`.
|
||||||
|
*/
|
||||||
|
export async function api<T = any>(
|
||||||
|
method: HttpMethod,
|
||||||
|
path: string,
|
||||||
|
auth?: string,
|
||||||
|
body?: any,
|
||||||
|
): Promise<ApiResult<T>> {
|
||||||
|
const headers: Record<string, string> = { '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<void> {
|
||||||
|
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<string, Partial<AuthRecord>> = {
|
||||||
|
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<void> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { describeModels } from './models';
|
||||||
|
import './setup';
|
||||||
|
|
||||||
|
describeModels();
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
// 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<void> {
|
||||||
|
if (snapshotId) await anvilRevert(snapshotId).catch(() => undefined);
|
||||||
|
await redisConnection.quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function anvilSnapshot(): Promise<string> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user