diff --git a/src/env.ts b/src/env.ts index b7f4cd1..4052ca5 100644 --- a/src/env.ts +++ b/src/env.ts @@ -58,6 +58,12 @@ const EnvSchema = z.object({ HOSTED_GATEWAY_PORT: z.coerce.number().int().positive().default(8080), LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), + // Operator PSS keypair file. All `prod_api` and `prod_worker` replicas + // share this key so providers can encrypt their PSS replies to a single + // identity. The compose mounts a named volume at /data so the key persists + // across deploys and is visible to every replica. + PSS_KEY_PATH: z.string().default('/data/pss.key'), + // Phase 2 — Stripe (optional) STRIPE_SECRET_KEY: z.string().optional(), STRIPE_WEBHOOK_SECRET: z.string().optional(), diff --git a/src/gateway/context.ts b/src/gateway/context.ts index afaa9e4..6c29af6 100644 --- a/src/gateway/context.ts +++ b/src/gateway/context.ts @@ -14,7 +14,7 @@ import { discoverUsableBatchId, downloadChunk, PssTransport } from '../t4t/lib/s 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 { loadOrCreatePssKey } from '../t4t/lib/keys'; import { ModelDiscovery } from '../t4t/modes/gateway/models'; import type { JobAckBody, @@ -89,12 +89,16 @@ async function bootGatewayContext(): Promise { 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(); + // Operator PSS keypair — persisted to disk so every api replica shares the + // same identity. Without this, each replica generates a fresh keypair at + // boot and providers encrypt their PSS replies to a key only one replica + // can decrypt, so most chat completions hang. + const pssKeys = loadOrCreatePssKey(env.PSS_KEY_PATH); const cipher = new EciesCipher(pssKeys.privateKey); + // Block until Bee returns a non-zero overlay; without this, a replica that + // races Bee's boot persists `selfOverlay = 0x00…00` in its gateway context + // and providers send PSS replies into the void. const selfOverlay = await resolveSelfOverlay(bee); const pss = new PssTransport({ bee, postageBatchId, logger, selfAddress: chain.address }); @@ -297,9 +301,17 @@ async function resolvePostageBatch(bee: Bee): Promise { } async function resolveSelfOverlay(bee: Bee): Promise { - const info = await bee.getNodeAddresses().catch(() => null); - const overlay = info?.overlay?.toString() ?? '00'.repeat(32); - return ('0x' + overlay.replace(/^0x/, '')) as Hex; + const zero = '0x' + '00'.repeat(32); + for (let attempt = 0; attempt < 60; attempt++) { + const info = await bee.getNodeAddresses().catch(() => null); + const overlay = info?.overlay?.toString(); + if (overlay && overlay.replace(/^0x/, '') !== '00'.repeat(32)) { + return ('0x' + overlay.replace(/^0x/, '')) as Hex; + } + logger.warn({ attempt }, 'bee.getNodeAddresses returned no overlay yet — retrying in 5s'); + await new Promise((r) => setTimeout(r, 5000)); + } + throw new Error('bee did not return a non-zero overlay after 60 attempts — refusing to boot with a zeroed selfOverlay'); } /** Update a GatewayJob row. Direct prisma write (not model.gatewayJob.update)