Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions PROJECT_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<root>/previews/<pr-N or br-slug-hash>`), `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 |
Expand Down
43 changes: 41 additions & 2 deletions app/api/agent/forge/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -327,6 +330,30 @@ export async function POST(request: NextRequest): Promise<NextResponse<AgentForg
const origin = request.nextUrl.origin
const outputLang = normalizeAgentForgeLang(body.lang)

// Spike 614 (phase-176): refuse before any spend when the forge surface is
// halted by its budget kill-switch.
if (isSurfaceHalted("forge")) {
return NextResponse.json(
{ success: false, error: "FORGE_SURFACE_HALTED", retryAfterSeconds: 300 },
{ status: 503, headers: { "Retry-After": "300" } },
)
}

// Spike 615 (phase-177): enforce the payer's daily tier quota BEFORE settle
// so an exhausted wallet never burns a real payment on a refused job.
const quota = await checkForgeQuota(payerAddress)
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: { "Retry-After": String(forgeBillingRetryAfterSeconds(quota)) } },
)
if (isAnalyticsWarehouseEnabled(payerAddress)) {
void recordWarehouseEvent({
surface: "forge",
Expand All @@ -349,8 +376,12 @@ export async function POST(request: NextRequest): Promise<NextResponse<AgentForg
)
}

// Override challenge amount with REQUIRED_AMOUNT from env if it looks more reliable.
const requiredAmount = parseInt(REQUIRED_AMOUNT, 10)
// Spike 612 (phase-174): resolve the amount for THIS payer instead of
// clobbering the challenge with the control constant — a variant-priced
// challenge must settle at the price it was issued at (higher variants would
// be underpaid, discounts overpaid, by a blind REQUIRED_AMOUNT override).
const requiredAmountStroops = await resolveForgePaymentAmount(payerAddress)
const requiredAmount = parseInt(requiredAmountStroops, 10)
if (Number.isFinite(requiredAmount) && requiredAmount > 0) {
challenge.amount = requiredAmount
}
Expand Down Expand Up @@ -393,6 +424,14 @@ export async function POST(request: NextRequest): Promise<NextResponse<AgentForg
const imageUrl = forgeResult.imageUrl ?? forgeResult.image_url ?? ""
const lore = forgeResult.lore ?? ""

// Spike 615 (phase-177): meter the accepted job (idempotent by jobId = txHash).
await recordForgeBillingEvent({
wallet: payerAddress,
jobId: settlementTxHash,
settlementTxHash,
amountStroops: String(challenge.amount),
}).catch(() => undefined)

// 7. Upload image to IPFS (best-effort)
const ipfsCid = await uploadImageToIpfs(imageUrl)

Expand Down
32 changes: 32 additions & 0 deletions app/api/cost-budgets/health/route.ts
Original file line number Diff line number Diff line change
@@ -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(),
})
}
48 changes: 48 additions & 0 deletions app/api/cron/cost-budgets/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
43 changes: 43 additions & 0 deletions app/api/experiments/forge/route.ts
Original file line number Diff line number Diff line change
@@ -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,
},
})
}
Loading