diff --git a/PROJECT_ARCHITECTURE.md b/PROJECT_ARCHITECTURE.md index 9cf666fd..fa49ecf8 100644 --- a/PROJECT_ARCHITECTURE.md +++ b/PROJECT_ARCHITECTURE.md @@ -212,6 +212,14 @@ is a fixed-size digest of the canonical payload, keeping it under wallet | `phase-167` | `NEXT_PUBLIC_FEATURE_PHASE_167` / `FEATURE_PHASE_167` | WalletConnect v2 mobile session resilience (`lib/walletconnect-resilience.ts`): configurable relay URL, multi-chain support, session persistence, chain-switch guard, and mobile deep-link return. Spike 623 (issue #486) | off | Unset var, restart — WalletConnect banner and reconnect actions disabled, kit falls back to default single-session behavior | | `phase-168` | `NEXT_PUBLIC_FEATURE_PHASE_168` / `FEATURE_PHASE_168` | xBull wallet full support and parity (`lib/xbull-adapter.ts`): Freighter-difference matrix, xBull module detection, sign result normalization, trustline error translation, and badge surfacing. Spike 624 (issue #487) | off | Unset var, restart — xBull transactions fall through generic kit handlers, badge hidden | | `phase-169` | `NEXT_PUBLIC_FEATURE_PHASE_169` / `FEATURE_PHASE_169` | Ledger blind-signing to clear-signing review for Soroban (`lib/ledger-soroban-decoder.ts`): invokeHostFunction pre-image decode, verification code, on-device review modal, Gate #13 preemptive validation, and hardware error humanizer. Spike 625 (issue #488) | off | Unset var, restart — Ledger transactions fall back to default blind-signing flow without preflight review modal | +| `phase-174` | `NEXT_PUBLIC_FEATURE_PHASE_174` / `FEATURE_PHASE_174` | A/B experimentation framework (`lib/experiments.ts`): sticky salted bucketing for forge/chamber copy and pricing variants, `experiment-assignments.json` persistence (hashed subjects), variant-aware 402 challenge/verifier floor, `GET /api/experiments/forge`. Spike 612 (issue #475) | off | Unset var, restart — all resolvers return control price/copy, experiments route 404s, assignments sidecar stays inert | +| `phase-175` | `NEXT_PUBLIC_FEATURE_PHASE_175` / `FEATURE_PHASE_175` | Search analytics (`lib/search-query-log.ts`): hashed query logs for world/profile search, zero-result rate, clickthrough beacons (`POST /api/search/click`), typo-correction effectiveness, closed counters. Spike 613 (issue #476) | off | Unset var, restart — recording no-ops, click route 404s, `search-query-log.json` stays inert | +| `phase-176` | `NEXT_PUBLIC_FEATURE_PHASE_176` / `FEATURE_PHASE_176` | Cost accounting per surface (`lib/cost-budgets.ts`): env budgets, persisted daily burn windows, transition-only webhook alerts, kill-switch (`503` + `Retry-After` on halted surfaces), hourly cron + health route. Spike 614 (issue #477) | off | Unset var, restart — charging no-ops, `isSurfaceHalted` always false, cron reports disabled, `cost-budgets.json` stays inert | +| `phase-177` | `NEXT_PUBLIC_FEATURE_PHASE_177` / `FEATURE_PHASE_177` | Forge billing and metering (`lib/forge/billing.ts`): per-wallet UTC-day quota with tier limits (`PHASE_FORGE_BILLING_TIERS`), idempotent usage events by jobId, `429` + `Retry-After` quota refusal, `forge-billing.json` sidecar. Spike 615 (issue #478) | off | Unset var, restart — quota always allows, recording no-ops, billing sidecar stays inert | +| `phase-170` | `NEXT_PUBLIC_FEATURE_PHASE_170` / `FEATURE_PHASE_170` | Structured log pipeline (`lib/log-pipeline.ts`): deep secret redaction, deterministic per-correlation-id info sampling (warn/error always kept), closed index-label set (`level`, `event`, `route`, `method`, `status_class`), and a bounded non-blocking HTTPS batcher (`LOG_SINK_URL`, https only). `api-observability` routes every line through it. Spike 606 (issue #470) — partial slice: no `waitUntil` flush on freeze, no counter export, ad-hoc `console.*` callers not migrated | off | Unset var, restart — `emitLog` is the identity and console lines are written exactly as before; in-memory buffer dropped | +| `phase-171` | `NEXT_PUBLIC_FEATURE_PHASE_171` / `FEATURE_PHASE_171` | Backup and DR snapshots (`lib/backup-snapshot.ts`): lock-consistent sidecar set + `VACUUM INTO` SQLite image, sha256/row-count manifest, `integrity_check`/`foreign_key_check` verify, tmp+rename restore with a restore marker that makes `/api/cron/faucet-health` return 503, and an AES-256-GCM sealed secrets envelope. `scripts/backup-drill.ts` runs snapshot→verify→restore→parity. Spike 605 (issue #469) — partial slice: no off-box shipping or scheduled snapshots | off | Unset var, restart — entry points throw `FLAG_DISABLED`, restore marker ignored; snapshots on disk are inert | +| `phase-172` | `NEXT_PUBLIC_FEATURE_PHASE_172` / `FEATURE_PHASE_172` | SQLite→Postgres rehearsal (`lib/db-parity.ts`): dialect helpers (`?`→`$n`, type map, explicit upsert), typed per-table parity (value, `type_coercion`, checksum), per-test schema names, and a `DualWriter` (primary first, bounded/timeout-guarded secondary, drift log, `primary-only` rollback). Needs `PHASE_DATABASE_URL` to enter `dual` mode. Spike 604 (issue #468) — partial slice: no Postgres driver, async CAS re-plumbing or pool storm test | off | Unset var, restart — `resolveDualWriteMode` returns `primary-only`; SQLite stays authoritative, nothing in production paths calls the module | +| `phase-173` | `NEXT_PUBLIC_FEATURE_PHASE_173` / `FEATURE_PHASE_173` | Vercel preview isolation (`lib/preview-isolation.ts`): per-preview data root (`/previews/`), `validatePhaseEnv` fails a preview that shares the production issuer, cron secret, contract IDs, or runs mainnet (declared via `PHASE_PROD_ISSUER`, `PHASE_PROD_CRON_SECRET_SHA256`, `PHASE_PROD_CONTRACT_IDS`), preview cron skipped unless `PHASE_PREVIEW_CRON_ENABLED=1`, and `x-vercel-cron` no longer trusted in previews. Spike 603 (issue #467) — partial slice: no per-PR contract deploy, preview CI job, or branch-DB seeding | off | Unset var, restart — data root, cron auth and env validation revert; per-preview directories stay inert | | `phase-170` | `NEXT_PUBLIC_FEATURE_PHASE_170` / `FEATURE_PHASE_170` | Albedo full retirement-or-parity plan with flagged sunset (`lib/albedo-sunset.ts`): call-site migration registry, parity-freeze (no new integrations, pinned `@albedo-link/intent` 0.12.0) and retired modes (`ALBEDO_SUNSET_MODE`), fail-closed Albedo signing with migration copy, and sunset banner UI. Spike 626 (issue #489) | off | Unset var, restart — sunset banner hidden, `/api/albedo/status` returns 404, every Albedo call site reverts to legacy guarded behavior | | `phase-171` | `NEXT_PUBLIC_FEATURE_PHASE_171` / `FEATURE_PHASE_171` | Freighter mobile browser support (`lib/freighter-mobile.ts`): mobile/in-app UA detection, injection-based support matrix, viewport-fit + theme-color adaptation, visualViewport keyboard avoidance, same-tab friendbot strategy, and a sessionStorage connect/sign return-flow marker. Spike 627 (issue #490) | off | Unset var, restart — mobile notice renders nothing, viewport/keyboard adaptation skipped, friendbot reverts to legacy `window.open` behavior | | `phase-172` | `NEXT_PUBLIC_FEATURE_PHASE_172` / `FEATURE_PHASE_172` | Biometric unlock for returning wallets with OS-gated session (`lib/biometric-unlock.ts`): WebAuthn platform-authenticator gate over an AES-GCM sealed session envelope (wallet-bound challenge, non-extractable HKDF key), attempt lockout with cooldown, and lock-screen tap-to-unlock middle state in `WalletProvider`. Spike 628 (issue #491) | off | Unset var, restart — lock screen never mounts, auto-restore behaves as before; `biometric-session-envelope` stays inert ciphertext | diff --git a/app/api/agent/forge/route.ts b/app/api/agent/forge/route.ts index f33452ba..0a4703ee 100644 --- a/app/api/agent/forge/route.ts +++ b/app/api/agent/forge/route.ts @@ -20,6 +20,9 @@ import { NextRequest, NextResponse } from "next/server" import { Keypair } from "@stellar/stellar-sdk" import { settleX402Challenge, type X402Challenge } from "@/lib/x402-agent-client" import { PHASER_LIQ_SYMBOL, REQUIRED_AMOUNT, stroopsToLiqDisplay } from "@/lib/phase-protocol" +import { resolveForgePaymentAmount } from "@/lib/forge/payment-verifier" +import { checkForgeQuota, forgeBillingRetryAfterSeconds, recordForgeBillingEvent } from "@/lib/forge/billing" +import { isSurfaceHalted } from "@/lib/cost-budgets" import { isAnalyticsWarehouseEnabled, recordWarehouseEvent } from "@/lib/analytics-warehouse" export const runtime = "nodejs" @@ -327,6 +330,30 @@ export async function POST(request: NextRequest): Promise 0) { challenge.amount = requiredAmount } @@ -393,6 +424,14 @@ export async function POST(request: NextRequest): Promise undefined) + // 7. Upload image to IPFS (best-effort) const ipfsCid = await uploadImageToIpfs(imageUrl) diff --git a/app/api/cost-budgets/health/route.ts b/app/api/cost-budgets/health/route.ts new file mode 100644 index 00000000..dbde05a9 --- /dev/null +++ b/app/api/cost-budgets/health/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server" +import { + COST_SURFACES, + evaluateSurfaceBudgets, + isCostBudgetsEnabled, + isSurfaceHalted, + snapshotCostBudgetCounters, +} from "@/lib/cost-budgets" + +export const dynamic = "force-dynamic" + +/** + * GET /api/cost-budgets/health — spike 614 (phase-176). + * + * Operator view of per-surface budgets, burn rate and kill-switch state. + * Evaluation runs without alerts (alerts belong to the cron) so a dashboard + * poll cannot spam webhooks. + */ +export async function GET() { + if (!isCostBudgetsEnabled()) { + return NextResponse.json({ error: "phase-176 flag disabled" }, { status: 404 }) + } + const evaluation = await evaluateSurfaceBudgets({ sendAlerts: false }) + return NextResponse.json({ + ok: true, + surfaces: evaluation.surfaces, + halted: evaluation.halted, + killSwitch: Object.fromEntries(COST_SURFACES.map((s) => [s, isSurfaceHalted(s)])), + counters: snapshotCostBudgetCounters(), + timestamp: new Date().toISOString(), + }) +} diff --git a/app/api/cron/cost-budgets/route.ts b/app/api/cron/cost-budgets/route.ts new file mode 100644 index 00000000..3d508b5a --- /dev/null +++ b/app/api/cron/cost-budgets/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server" +import { evaluateSurfaceBudgets } from "@/lib/cost-budgets" + +export const dynamic = "force-dynamic" +export const maxDuration = 60 + +function validateCronAuth(req: NextRequest): boolean { + const authHeader = req.headers.get("authorization") + const cronSecret = process.env.CRON_SECRET?.trim() + + if (process.env.NODE_ENV === "development" && !cronSecret) { + return true + } + if (req.headers.get("x-vercel-cron")) { + return true + } + if (!cronSecret) return false + if (!authHeader || !authHeader.startsWith("Bearer ")) return false + return authHeader.substring(7) === cronSecret +} + +/** + * GET /api/cron/cost-budgets — spike 614 (phase-176). + * + * Hourly evaluation of per-surface cost budgets: rolls windows, refreshes the + * halt cache, and fires deduped webhook alerts on warning/critical/recovery + * transitions. Response envelope matches the faucet-health cron. + */ +export async function GET(req: NextRequest) { + if (!validateCronAuth(req)) { + return NextResponse.json({ error: "Unauthorized - valid cron secret required" }, { status: 401 }) + } + try { + const evaluation = await evaluateSurfaceBudgets({ sendAlerts: true }) + return NextResponse.json({ + ok: true, + status: evaluation.halted.length > 0 ? "halted" : evaluation.enabled ? "ok" : "disabled", + message: evaluation.enabled + ? `${evaluation.surfaces.length} surfaces evaluated, ${evaluation.halted.length} halted, ${evaluation.alerted.length} alerted` + : "phase-176 flag disabled", + results: evaluation, + timestamp: new Date().toISOString(), + }) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + return NextResponse.json({ ok: false, error: msg }, { status: 500 }) + } +} diff --git a/app/api/experiments/forge/route.ts b/app/api/experiments/forge/route.ts new file mode 100644 index 00000000..35cd910c --- /dev/null +++ b/app/api/experiments/forge/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server" +import { isExperimentationEnabled, resolveForgeExperimentCopyKey, resolveForgeRequiredAmountStroops } from "@/lib/experiments" +import { forgePriceDisplay } from "@/lib/forge/payment-verifier" +import { pickForgeExperimentCopy } from "@/lib/phase-copy" + +export const dynamic = "force-dynamic" + +/** + * GET /api/experiments/forge — spike 612 (phase-174). + * + * Public, read-only view of the forge price/copy experiment for a subject. + * The wallet is hashed server-side and never echoed or stored raw; the sticky + * assignment store is only touched through the resolver. 404 when the flag is + * off so the route is invisible in the zero-regression path. + */ +export async function GET(request: NextRequest) { + if (!isExperimentationEnabled()) { + return NextResponse.json({ error: "phase-174 flag disabled" }, { status: 404 }) + } + const wallet = request.nextUrl.searchParams.get("wallet")?.trim() || undefined + const lang = request.nextUrl.searchParams.get("lang")?.trim() + + const [price, copyKey] = await Promise.all([ + resolveForgeRequiredAmountStroops(wallet), + resolveForgeExperimentCopyKey(wallet), + ]) + const variantCopy = pickForgeExperimentCopy(lang, copyKey) + + return NextResponse.json({ + experimentId: price.experimentId, + variantId: price.variantId, + copyKey, + amountStroops: price.amountStroops, + priceDisplay: forgePriceDisplay(price.amountStroops), + label: variantCopy.label, + copy: { + // `{amount}` is left intact: the forge page substitutes the variant + // price, the chamber substitutes its on-chain collection price. + paymentGateExplainer: variantCopy.paymentGateExplainer, + paymentGateHint: variantCopy.paymentGateHint, + }, + }) +} diff --git a/app/api/forge-agent/route.ts b/app/api/forge-agent/route.ts index 47bf9d0d..32e1f045 100644 --- a/app/api/forge-agent/route.ts +++ b/app/api/forge-agent/route.ts @@ -8,11 +8,18 @@ import { extractSettlementReceiptTxHash, buildLegacyChallenge, forgePriceDisplay, + resolveForgePaymentAmount, X402_NETWORK, } from "@/lib/forge/payment-verifier" import { runForgePipeline } from "@/lib/forge/pipeline" -import { tokenContractIdForServer, REQUIRED_AMOUNT } from "@/lib/phase-protocol" +import { tokenContractIdForServer } from "@/lib/phase-protocol" import { isSettlementUsed, markSettlementUsedIfUnused } from "@/lib/settlement-store" +import { + checkForgeQuota, + recordForgeBillingEvent, + forgeBillingRetryAfterSeconds, +} from "@/lib/forge/billing" +import { chargeSurfaceCost, isSurfaceHalted } from "@/lib/cost-budgets" import { nanobananaApiKeyConfigured, nanobananaAsyncWebhookEnabled, @@ -30,15 +37,19 @@ export const dynamic = "force-dynamic" const PHASE_LIQ_TOKEN_CONTRACT = tokenContractIdForServer() const ERR_SETTLEMENT_REJECTED = "[ ERROR: SETTLEMENT_REJECTED_BY_FACILITATOR ]" -function paymentRequiredResponse(request: NextRequest) { +async function paymentRequiredResponse(request: NextRequest, payerAddress?: string) { const origin = request.nextUrl.origin - const challenge = buildLegacyChallenge(origin, request.nextUrl.pathname) - const paymentRequirements = buildOfficialPaymentRequirements(origin) + // Spike 612: challenge amount follows the payer's price variant (control when + // flag off / no payer / holdout). One resolved amount feeds challenge, header + // and verifier so the three can never diverge. + const amountStroops = await resolveForgePaymentAmount(payerAddress) + const challenge = buildLegacyChallenge(origin, request.nextUrl.pathname, amountStroops) + const paymentRequirements = buildOfficialPaymentRequirements(origin, amountStroops) const b64 = Buffer.from(JSON.stringify(challenge)).toString("base64") const body: Record = { success: false, error: "Payment Required", - priceDisplay: forgePriceDisplay(), + priceDisplay: forgePriceDisplay(amountStroops), message: "Se requiere pago en PHASELQ. Tras confirmar on-chain, reintenta con Authorization o settlementTxHash.", challenge, @@ -48,7 +59,7 @@ function paymentRequiredResponse(request: NextRequest) { status: 402, headers: { "WWW-Authenticate": `x402 token="${b64}", amount="${challenge.amount}", facilitator="${challenge.facilitator}", network="${X402_NETWORK}"`, - "X-Required-Amount": REQUIRED_AMOUNT, + "X-Required-Amount": amountStroops, "X-Token-Address": PHASE_LIQ_TOKEN_CONTRACT, "X-Facilitator": challenge.facilitator, "X-X402-Network": X402_NETWORK, @@ -83,7 +94,9 @@ export async function POST(request: NextRequest) { } warnPhaserLiqSacMismatchOnce(PHASE_LIQ_TOKEN_CONTRACT, "forge-agent") - const paymentRequirements = buildOfficialPaymentRequirements(request.nextUrl.origin) + const payerForPricing = body.payerAddress?.trim() + const requiredAmountStroops = await resolveForgePaymentAmount(payerForPricing) + const paymentRequirements = buildOfficialPaymentRequirements(request.nextUrl.origin, requiredAmountStroops) const auth = request.headers.get("authorization") const receipt = extractSettlementReceiptTxHash(auth, body) @@ -102,7 +115,7 @@ export async function POST(request: NextRequest) { { status: 403, headers: { "x-correlation-id": correlationId } }, ) } - if (resolution === "missing") return paymentRequiredResponse(request) + if (resolution === "missing") return paymentRequiredResponse(request, payerForPricing) if (receipt) { if (isAnalyticsWarehouseEnabled()) { @@ -137,6 +150,42 @@ export async function POST(request: NextRequest) { const txHash = receipt ?? body.settlementTxHash?.trim() ?? "" + // Spike 614 (phase-176): budget kill-switch — a halted surface refuses new + // work with 503 + Retry-After before any spend happens. + if (isSurfaceHalted("forge")) { + return NextResponse.json( + { success: false, error: "FORGE_SURFACE_HALTED", retryAfterSeconds: 300 }, + { status: 503, headers: { "x-correlation-id": correlationId, "Retry-After": "300" } }, + ) + } + + // Spike 615 (phase-177): paid-tier quota gate. Enforced after settlement + // verification so a quota-exhausted wallet cannot double-spend a settlement + // into a job; the settlement idempotency store above still fences replays. + const quotaPayer = payerForPricing ?? body.payerAddress?.trim() + if (quotaPayer) { + const quota = await checkForgeQuota(quotaPayer) + if (!quota.allowed) { + return NextResponse.json( + { + success: false, + error: "FORGE_QUOTA_EXCEEDED", + tierId: quota.tierId, + usedToday: quota.used, + dailyLimit: quota.limit, + resetAt: quota.resetAt, + }, + { + status: 429, + headers: { + "x-correlation-id": correlationId, + "Retry-After": String(forgeBillingRetryAfterSeconds(quota)), + }, + }, + ) + } + } + // ── Async mode: NanoBanana webhook enabled ────────────────────────────────── // When NANOBANANA_WEBHOOK_SECRET + NANOBANANA_CALLBACK_URL are both set, // submit the image generation task and return immediately with a jobId. @@ -169,6 +218,18 @@ export async function POST(request: NextRequest) { lang: body.lang, }) + // Spike 615/614: meter the accepted job (idempotent by jobId) and book + // the surface cost against the forge budget. + if (quotaPayer) { + await recordForgeBillingEvent({ + wallet: quotaPayer, + jobId: job.id, + settlementTxHash: txHash, + amountStroops: requiredAmountStroops, + }).catch(() => undefined) + } + void chargeSurfaceCost("forge", 20, { requestId: correlationId }).catch(() => undefined) + // Start lore generation in the background while image is being generated // (lore is independent of image and typically finishes in 3–10s) void (async () => { @@ -214,6 +275,15 @@ export async function POST(request: NextRequest) { { ...body, prompt: body.prompt as string }, correlationId, ) + if (quotaPayer) { + await recordForgeBillingEvent({ + wallet: quotaPayer, + jobId: result.jobId, + settlementTxHash: txHash, + amountStroops: requiredAmountStroops, + }).catch(() => undefined) + } + void chargeSurfaceCost("forge", 20, { requestId: correlationId }).catch(() => undefined) return NextResponse.json(result, { headers: { "x-correlation-id": correlationId } }) } catch (e) { const msg = e instanceof Error ? e.message : String(e) diff --git a/app/api/profile/search/route.ts b/app/api/profile/search/route.ts index a2a1afa9..4c1b349f 100644 --- a/app/api/profile/search/route.ts +++ b/app/api/profile/search/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server" import { readFile } from "node:fs/promises" import { serverDataJsonPath } from "@/lib/server-data-paths" +import { isSearchAnalyticsEnabled, recordSearchQuery, searchQueryHash } from "@/lib/search-query-log" import type { ProfileData } from "@/lib/profile-store" export const runtime = "nodejs" @@ -157,5 +158,19 @@ export async function GET(request: NextRequest) { } } + // Spike 613 (phase-175): non-blocking hashed query log. q<2 chars matches + // everything (browse), so only real queries (q>=2) are logged. + if (q.length >= 2 && isSearchAnalyticsEnabled()) { + const correctedFrom = request.nextUrl.searchParams.get("q0")?.trim() || undefined + void recordSearchQuery({ + surface: "profile", + kind: "query", + query_hash: searchQueryHash(q), + result_count: results.length, + session_id: request.nextUrl.searchParams.get("sid")?.trim() || undefined, + corrected_from_hash: correctedFrom ? searchQueryHash(correctedFrom) : undefined, + }).catch(() => undefined) + } + return NextResponse.json({ results, suggested, totalCollectors, nextCursor }) } diff --git a/app/api/search/click/route.ts b/app/api/search/click/route.ts new file mode 100644 index 00000000..83a0efed --- /dev/null +++ b/app/api/search/click/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server" +import { + isSearchAnalyticsEnabled, + recordSearchClick, + searchQueryHash, + type SearchSurface, +} from "@/lib/search-query-log" + +export const dynamic = "force-dynamic" + +/** + * POST /api/search/click — spike 613 (phase-175). + * + * Clickthrough beacon for search results. Body: { surface, query, result_ref?, session_id? }. + * Only the sha256 of the query and result reference are stored. 404 when the + * flag is off; 400 on invalid input; 204 on accept. + */ +export async function POST(request: NextRequest) { + if (!isSearchAnalyticsEnabled()) { + return NextResponse.json({ error: "phase-175 flag disabled" }, { status: 404 }) + } + let body: { surface?: string; query?: string; result_ref?: string; session_id?: string } + try { + body = (await request.json()) as typeof body + } catch { + return NextResponse.json({ error: "invalid JSON" }, { status: 400 }) + } + const surface = body.surface?.trim() as SearchSurface | undefined + const query = body.query?.trim() ?? "" + if (!surface || !query) { + return NextResponse.json({ error: "surface and query are required" }, { status: 400 }) + } + try { + const event = await recordSearchClick({ + surface, + query, + resultRef: body.result_ref?.trim() || undefined, + sessionId: body.session_id?.trim() || undefined, + }) + return NextResponse.json( + { ok: true, recorded: event !== null, query_hash: searchQueryHash(query) }, + { status: 200 }, + ) + } catch { + return NextResponse.json({ error: "invalid search click payload" }, { status: 400 }) + } +} diff --git a/app/api/world/search/route.ts b/app/api/world/search/route.ts index cfbf1ecf..15919b57 100644 --- a/app/api/world/search/route.ts +++ b/app/api/world/search/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server" import { isNarrativeSearchEnabled, searchNarratives } from "@/lib/narrative-search" +import { isSearchAnalyticsEnabled, recordSearchQuery, searchQueryHash } from "@/lib/search-query-log" export const runtime = "nodejs" export const dynamic = "force-dynamic" @@ -21,6 +22,23 @@ export async function GET(request: NextRequest) { const location = params.get("location")?.trim() || undefined const text = params.get("q")?.trim() || undefined + const startedAt = Date.now() const results = await searchNarratives({ entity, location, text }) + + // Spike 613 (phase-175): non-blocking hashed query log — only free-text + // searches are logged (entity/location filters are not typo-relevant). + if (text && isSearchAnalyticsEnabled()) { + const correctedFrom = params.get("q0")?.trim() || undefined + void recordSearchQuery({ + surface: "world", + kind: "query", + query_hash: searchQueryHash(text), + result_count: results.length, + duration_ms: Date.now() - startedAt, + session_id: params.get("sid")?.trim() || undefined, + corrected_from_hash: correctedFrom ? searchQueryHash(correctedFrom) : undefined, + }).catch(() => undefined) + } + return NextResponse.json({ results, total: results.length }) } diff --git a/app/api/x402/route.ts b/app/api/x402/route.ts index 2e75b514..03deeca6 100644 --- a/app/api/x402/route.ts +++ b/app/api/x402/route.ts @@ -6,6 +6,7 @@ import { stroopsToLiqDisplay, tokenContractIdForServer, } from "@/lib/phase-protocol" +import { resolveForgeRequiredAmountStroops } from "@/lib/experiments" export const dynamic = 'force-dynamic' @@ -28,8 +29,8 @@ type LocalX402Token = { contract_id?: string } -function parseRequiredAmount(): number { - const parsed = Number.parseInt(REQUIRED_AMOUNT, 10) +function parseRequiredAmount(amountStroops: string = REQUIRED_AMOUNT): number { + const parsed = Number.parseInt(amountStroops, 10) return Number.isFinite(parsed) && parsed > 0 ? parsed : 0 } @@ -49,10 +50,10 @@ function decodeX402Token(raw: string): LocalX402Token | null { } } -function isSatisfiedPayment(payload: LocalX402Token | null): boolean { +function isSatisfiedPayment(payload: LocalX402Token | null, requiredStroops: string = REQUIRED_AMOUNT): boolean { if (!payload) return false const amount = Number(payload.amount) - return Boolean(payload.invoice) && Number.isFinite(amount) && amount >= parseRequiredAmount() + return Boolean(payload.invoice) && Number.isFinite(amount) && amount >= parseRequiredAmount(requiredStroops) } export async function GET(request: NextRequest) { @@ -61,11 +62,14 @@ export async function GET(request: NextRequest) { } const authHeader = request.headers.get("authorization") const facilitator = facilitatorUrl(request) + // Spike 612: variant-aware floor when a payer address is supplied; control otherwise. + const payerAddress = request.nextUrl.searchParams.get("payer")?.trim() || undefined + const requiredStroops = (await resolveForgeRequiredAmountStroops(payerAddress)).amountStroops if (authHeader && authHeader.startsWith("x402 ")) { const token = authHeader.replace("x402 ", "").trim() const payload = decodeX402Token(token) - if (isSatisfiedPayment(payload)) { + if (isSatisfiedPayment(payload, requiredStroops)) { return NextResponse.json({ status: "success", data: { @@ -85,8 +89,8 @@ export async function GET(request: NextRequest) { token: PHASE_LIQ_TOKEN_CONTRACT, contract_id: PHASE_PROTOCOL_CONTRACT, token_contract: PHASE_LIQ_TOKEN_CONTRACT, - amount: parseRequiredAmount(), - priceDisplay: `${stroopsToLiqDisplay(REQUIRED_AMOUNT)} ${PHASER_LIQ_SYMBOL}`, + amount: parseRequiredAmount(requiredStroops), + priceDisplay: `${stroopsToLiqDisplay(requiredStroops)} ${PHASER_LIQ_SYMBOL}`, facilitator, invoice: `inv_${Date.now()}`, resource: request.nextUrl.pathname, @@ -104,7 +108,7 @@ export async function GET(request: NextRequest) { status: 402, headers: { "WWW-Authenticate": `x402 token="${challengeBase64}", amount="${challenge.amount}", facilitator="${facilitator}", network="${X402_NETWORK}"`, - "X-Required-Amount": REQUIRED_AMOUNT, + "X-Required-Amount": requiredStroops, "X-Token-Address": PHASE_LIQ_TOKEN_CONTRACT, "X-Facilitator": facilitator, "X-X402-Network": X402_NETWORK, @@ -133,7 +137,8 @@ export async function POST(request: NextRequest) { } const payload = decodeX402Token(payment_token) - if (!payload || !isSatisfiedPayment(payload)) { + const requiredStroops = (await resolveForgeRequiredAmountStroops(user_address)).amountStroops + if (!payload || !isSatisfiedPayment(payload, requiredStroops)) { return NextResponse.json({ error: "Invalid or insufficient x402 payment token" }, { status: 401 }) } diff --git a/app/api/x402/settle/route.ts b/app/api/x402/settle/route.ts index 6f8458e9..c99c26c4 100644 --- a/app/api/x402/settle/route.ts +++ b/app/api/x402/settle/route.ts @@ -2,6 +2,7 @@ import { complianceGate } from "@/lib/compliance" import { isFeatureEnabled } from "@/lib/feature-flags" import { NextRequest, NextResponse } from "next/server" import { REQUIRED_AMOUNT, tokenContractIdForServer } from "@/lib/phase-protocol" +import { resolveForgeRequiredAmountStroops } from "@/lib/experiments" export const dynamic = 'force-dynamic' @@ -27,10 +28,10 @@ function decodeX402Token(raw: string): LocalX402Token | null { } } -function isSatisfiedPayment(payload: LocalX402Token | null): boolean { +function isSatisfiedPayment(payload: LocalX402Token | null, requiredStroops: string = REQUIRED_AMOUNT): boolean { if (!payload) return false const amount = Number(payload.amount) - const required = Number.parseInt(REQUIRED_AMOUNT, 10) + const required = Number.parseInt(requiredStroops, 10) return Boolean(payload.invoice) && Number.isFinite(amount) && Number.isFinite(required) && amount >= required } @@ -50,6 +51,9 @@ export async function POST(request: NextRequest) { } const payload = decodeX402Token(token) + // Spike 612: variant-aware floor for the settling payer; control otherwise. + const requiredStroops = (await resolveForgeRequiredAmountStroops(user)).amountStroops + if (!isSatisfiedPayment(payload, requiredStroops)) { const block = await complianceGate(body.user_address ?? "", String(payload?.amount ?? ""), "x402") if (block) return block if (!isSatisfiedPayment(payload)) { diff --git a/app/api/x402/verify/route.ts b/app/api/x402/verify/route.ts index 3b899fc2..6d98d8bc 100644 --- a/app/api/x402/verify/route.ts +++ b/app/api/x402/verify/route.ts @@ -3,6 +3,7 @@ import { isFeatureEnabled } from "@/lib/feature-flags" import { NextRequest, NextResponse } from "next/server" import { REQUIRED_AMOUNT } from "@/lib/phase-protocol" import { isSettlementUsed } from "@/lib/settlement-store" +import { resolveForgeRequiredAmountStroops } from "@/lib/experiments" export const dynamic = 'force-dynamic' @@ -26,10 +27,10 @@ function decodeX402Token(raw: string): LocalX402Token | null { } } -function isMaisonedPayment(payload: LocalX402Token | null): boolean { +function isMaisonedPayment(payload: LocalX402Token | null, requiredStroops: string = REQUIRED_AMOUNT): boolean { if (!payload) return false const amount = Number(payload.amount) - const required = Number.parseInt(REQUIRED_AMOUNT, 10) + const required = Number.parseInt(requiredStroops, 10) return Boolean(payload.invoice) && Number.isFinite(amount) && Number.isFinite(required) && amount >= required } @@ -46,6 +47,9 @@ export async function POST(request: NextRequest) { if (!token) return NextResponse.json({ error: "Missing payment_token" }, { status: 400 }) const payload = decodeX402Token(token) + // Spike 612: variant-aware floor when the caller supplies the payer; control otherwise. + const requiredStroops = (await resolveForgeRequiredAmountStroops(body.user_address?.trim())).amountStroops + const verified = isMaisonedPayment(payload, requiredStroops) const block = await complianceGate(body.user_address ?? "", String(payload?.amount ?? ""), "x402") if (block) return block const verified = isMaisonedPayment(payload) diff --git a/app/forge/page.tsx b/app/forge/page.tsx index fd648183..b0a72e2d 100644 --- a/app/forge/page.tsx +++ b/app/forge/page.tsx @@ -230,6 +230,28 @@ export default function ForgePage() { const { address, connect, disconnect, connecting, refresh, isHardwareWallet, requestLedgerReview } = useWallet() + // Spike 612 (phase-174): variant hint shown while the 402 gate is open. The + // challenge amount itself is resolved server-side; this is display copy only. + const [expGateHint, setExpGateHint] = useState(null) + useEffect(() => { + if (!isFeatureEnabled("phase-174")) return + let cancelled = false + const params = new URLSearchParams({ lang }) + if (address) params.set("wallet", address) + void fetch(`/api/experiments/forge?${params.toString()}`) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + const hint = data?.copy?.paymentGateHint + if (!cancelled && typeof hint === "string" && data?.variantId !== "control") { + setExpGateHint(hint) + } + }) + .catch(() => undefined) + return () => { + cancelled = true + } + }, [address, lang]) + const [name, setName] = useState("") const [priceLiq, setPriceLiq] = useState("1") @@ -1077,7 +1099,7 @@ export default function ForgePage() { : agentState === "FORGING_MATTER" ? forgingTerminalLines[agentTickerIdx % forgingTerminalLines.length] : agentState === "AWAITING_PAYMENT" - ? f.forgeCtaGenerateContacting + ? expGateHint ?? f.forgeCtaGenerateContacting : "" const initiatePrimaryLabel = diff --git a/components/fusion-chamber.tsx b/components/fusion-chamber.tsx index adecf61c..0c56190a 100644 --- a/components/fusion-chamber.tsx +++ b/components/fusion-chamber.tsx @@ -195,6 +195,30 @@ export function FusionChamber() { const [narrativeData, setNarrativeData] = useState(null) const [narrativeLoading, setNarrativeLoading] = useState(false) + // Spike 612 (phase-174): experiment copy variant for the payment gate. The + // chamber's actual price is the on-chain collection price (authoritative); + // only the explainer copy varies here — price variants live in the + // forge-agent 402 flow where the server resolves and verifies one amount. + const [expGateCopy, setExpGateCopy] = useState<{ paymentGateExplainer: string } | null>(null) + useEffect(() => { + if (!isFeatureEnabled("phase-174")) return + let cancelled = false + const params = new URLSearchParams({ lang }) + if (address) params.set("wallet", address) + void fetch(`/api/experiments/forge?${params.toString()}`) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + const explainer = data?.copy?.paymentGateExplainer + if (!cancelled && typeof explainer === "string" && data?.variantId !== "control") { + setExpGateCopy({ paymentGateExplainer: explainer }) + } + }) + .catch(() => undefined) + return () => { + cancelled = true + } + }, [address, lang]) + const effectivePriceStroops = useMemo(() => { if (collectionId > 0 && collectionInfo?.price) return collectionInfo.price return REQUIRED_AMOUNT @@ -2333,7 +2357,7 @@ export function FusionChamber() { > {!canInitializeGenesisSupply && !processing && (

- {ch.paymentGateExplainer.replace("{amount}", stroopsToLiqDisplay(effectivePriceStroops))} + {(expGateCopy?.paymentGateExplainer ?? ch.paymentGateExplainer).replace("{amount}", stroopsToLiqDisplay(effectivePriceStroops))}

)}