Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2bf2010
chore: restore flag and counter union members dropped in earlier merges
ExcelDsigN-tech Oct 17, 2026
2fa7dab
fix: undo 0d14b9a encoding corruption and restore the #33 chamber mod…
ExcelDsigN-tech Oct 17, 2026
d2824b5
fix: restore code dropped by earlier merges
ExcelDsigN-tech Oct 17, 2026
515e88f
fix: type errors on main, including three real bugs
ExcelDsigN-tech Oct 17, 2026
c1ca72f
test: fix harness bugs in four suites
ExcelDsigN-tech Oct 17, 2026
d63109d
build: add eslint, eslint-config-next and dotenv as devDependencies
ExcelDsigN-tech Oct 17, 2026
58bb24b
fix(contracts): build on current stable rustc, fix mock-token test
ExcelDsigN-tech Oct 17, 2026
f2185da
ci: make typecheck blocking and run vitest; document the baseline repair
ExcelDsigN-tech Oct 17, 2026
be2c748
docs: CI typecheck and tests now gate PRs
ExcelDsigN-tech Oct 17, 2026
821ee48
fix: every page crashed on hydration (node:crypto randomUUID in the c…
ExcelDsigN-tech Oct 17, 2026
b4243cd
feat(api): version negotiation, deprecation headers and contract gate…
ExcelDsigN-tech Oct 17, 2026
906c454
fix(api): keep route Vary tokens; add live proof and PR draft (spike …
ExcelDsigN-tech Oct 17, 2026
b395833
docs(spike 663): production-build proof results, flag on and off
ExcelDsigN-tech Oct 18, 2026
33a42f6
fix: verify wallet signatures per SEP-53 (sha256 of prefix + message)
ExcelDsigN-tech Oct 18, 2026
6d4c0cc
Merge branch 'chore/fix-main-baseline' into feat/api-versioning-depre…
ExcelDsigN-tech Oct 18, 2026
cb1c282
Merge upstream main (#573) into chore/fix-main-baseline
ExcelDsigN-tech Oct 18, 2026
75453e0
Merge chore/fix-main-baseline (upstream #573) into feat/api-versionin…
ExcelDsigN-tech Oct 18, 2026
64f390a
docs(663): reference baseline PR #574
ExcelDsigN-tech Oct 18, 2026
1923551
docs: tidy Depends on section
ExcelDsigN-tech Oct 18, 2026
ed9cd73
Merge upstream main (11cb3bc) into chore/fix-main-baseline
ExcelDsigN-tech Oct 20, 2026
e6ebb29
refactor(flags): rename phase-163 to phase-api-versioning
ExcelDsigN-tech Oct 20, 2026
64d33a4
Merge branch 'chore/fix-main-baseline' into feat/api-versioning-depre…
ExcelDsigN-tech Oct 20, 2026
87c2cd1
docs(spike-663): note the flag rename in the PR draft and lab results
ExcelDsigN-tech Oct 20, 2026
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
20 changes: 17 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,31 @@ jobs:
cache: npm
- name: Install locked dependencies
run: npm ci
- name: API contract (breaking-change gate, spike 663)
# Breaking changes are measured against the target branch snapshot, so a
# regenerated docs/api/openapi.json cannot hide them. Exit 1 = breaking
# without a version bump + changelog entry, exit 2 = stale snapshot.
env:
BASE_REF: ${{ github.base_ref }}
run: |
BASE_ARGS=""
if [ -n "$BASE_REF" ]; then
git fetch --no-tags --depth=1 origin "$BASE_REF"
if git show "origin/$BASE_REF:docs/api/openapi.json" > "$RUNNER_TEMP/openapi-base.json" 2>/dev/null; then
BASE_ARGS="--base $RUNNER_TEMP/openapi-base.json"
fi
fi
npm run api:contract -- $BASE_ARGS
- name: Lint
# eslint had no config/devDependency before this change (npm run lint
# failed outright); now that it runs, ~95 pre-existing errors surface
# across unrelated files. Non-blocking until tracked and fixed separately.
continue-on-error: true
run: npm run lint
- name: Type check
# Pre-existing type errors exist outside this change's scope; surfaced but
# non-blocking until tracked and fixed separately.
continue-on-error: true
run: npm run typecheck
- name: Unit tests
run: npm test
- name: Build
run: npm run build
env:
Expand Down
4 changes: 2 additions & 2 deletions PROJECT_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ Owns:
- **Deterministic status codes** (validation, cooldown, authorization, pending).
- **No implicit success**: all state transitions explicit and auditable.
- **Compatibility routes** allowed if typed and documented.
- **Versioned contract** (`phase-api-versioning`): `lib/api-contract.ts` is the source of truth for the public surface; `docs/api/openapi.json` and `docs/api/changelog.json` are rendered from it. Middleware negotiates `X-API-Version` / `Accept-Version` on `/api/*`, echoes `X-API-Version`, answers unknown versions with `406 UNSUPPORTED_API_VERSION` and sunset ones with `410 API_VERSION_SUNSET`, and adds `Deprecation` / `Sunset` / `Link` from `ROUTE_DEPRECATIONS`. CI blocks a breaking diff against the target branch unless a new version and a `breaking: true` changelog entry ship with it.
- **Optimistic concurrency on shared mutable records**: a record that more than one
client can edit carries an integer `version`. Reads publish it as an `ETag`;
mutations may echo it back as `If-Match` (or `parent_version` for appends) and
Expand Down Expand Up @@ -211,8 +212,7 @@ 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-api-versioning` | `NEXT_PUBLIC_FEATURE_PHASE_API_VERSIONING` / `FEATURE_PHASE_API_VERSIONING` | API versioning (spike 663 / #526): middleware negotiation for `/api/*`, `X-API-Version` echo, typed `406`/`410`, RFC 9745/8594 deprecation headers, `GET /api/versions` (summary or `?format=openapi`). The CI contract gate (`npm run api:contract`) runs regardless of the flag | off | Unset var, restart — no negotiation or version headers, `/api/versions` returns 404; nothing persisted |

Flags are read via `lib/feature-flags.ts:isFeatureEnabled`. Client flags use `NEXT_PUBLIC_*`, server also accepts `FEATURE_*`. Zero regression when off.

Expand Down
4 changes: 2 additions & 2 deletions app/api/governance/ve-lock/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,15 @@ export async function POST(request: NextRequest) {
if (!claimRes.ok) {
return NextResponse.json({ error: claimRes.error }, { status: 400, headers: CORS });
}
return NextResponse.json({ ok: true, ...claimRes }, { status: 200, headers: CORS });
return NextResponse.json({ ...claimRes }, { status: 200, headers: CORS });
}

if (action === "withdraw") {
const withdrawRes = withdrawVeLock(wallet);
if (!withdrawRes.ok) {
return NextResponse.json({ error: withdrawRes.error }, { status: 400, headers: CORS });
}
return NextResponse.json({ ok: true, ...withdrawRes }, { status: 200, headers: CORS });
return NextResponse.json({ ...withdrawRes }, { status: 200, headers: CORS });
}

if (action === "lock") {
Expand Down
2 changes: 1 addition & 1 deletion app/api/ipfs/[...cid]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export async function GET(
// the 1-year shared cache.
const { verifyCID, cacheControlForCid } = await import("@/lib/cid-verification")
const requestedCid = parsed.data[0] ?? ""
const verification = verifyCID(requestedCid, result.bytes)
const verification = verifyCID(requestedCid, new Uint8Array(result.bytes))
const gated = request.headers.get("x-phase-gated") === "1"

if (!verification.ok && verification.code === "CID_MISMATCH") {
Expand Down
7 changes: 7 additions & 0 deletions app/api/market/[id]/offers/[offer_id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export async function POST(

// If accepted, mark listing as sold
let royalty: Awaited<ReturnType<typeof recordRoyaltyPayout>> | null = null
let splits: Awaited<ReturnType<typeof recordCollabSplits>> | null = null
if (action === "accept") {
await soldListing(id)

Expand All @@ -65,6 +66,12 @@ export async function POST(
}
}

// Spike 674 (flag-gated): N-way fan-out at accept (dust → first payee, idempotent per offer).
if (isPhaseCollabSplitsEnabled() && listing.split_payees && listing.split_payees.length > 0) {
const computed = computeCollabSplits(offer.amount_phaselq, listing.split_payees)
splits = await recordCollabSplits(id, offer.id, computed)
}

// phase-140: split proceeds with the original creator on a secondary sale.
if (isPhase140Enabled()) {
const split = computeRoyaltySplit(listing, offer.amount_phaselq)
Expand Down
17 changes: 15 additions & 2 deletions app/api/nft-index/subscribe/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,21 @@ export function validateWebhookUrl(
if (/^\d+$/.test(host) || /^\[/.test(host) || host.includes(":")) {
return { ok: false, error: "webhookUrl must use a public hostname", reason: "ip_literal" }
}
if (host === "169.254.169.254" || host.startsWith("10.") || host.startsWith("192.168.")) {
return { ok: false, error: "webhookUrl must use a public hostname", reason: "private_network" }
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
if (v4) {
const [a, b] = [Number(v4[1]), Number(v4[2])]
if (a === 127 || a === 0) {
return { ok: false, error: "webhookUrl host is not routable", reason: "loopback" }
}
const isPrivate =
a === 10 ||
(a === 100 && b >= 64 && b <= 127) || // CGNAT
(a === 169 && b === 254) || // link-local, incl. cloud metadata
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168)
if (isPrivate) {
return { ok: false, error: "webhookUrl must use a public hostname", reason: "private_network" }
}
}
return { ok: true, url }
}
Expand Down
14 changes: 5 additions & 9 deletions app/api/notifications/winback/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type NextRequest, NextResponse } from "next/server";
import { isFeatureEnabled } from "@/lib/feature-flags";
import { evaluateWinbackCampaign, recordWinbackDispatch, getWinbackStore } from "@/lib/churn-winback";
import { addNotification } from "@/lib/notification-store";
import { createNotification } from "@/lib/notification-store";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -88,16 +88,12 @@ export async function POST(request: NextRequest) {
if (body.dispatch && evaluation.eligible && evaluation.cohort !== "control") {
await recordWinbackDispatch(wallet, evaluation.cohort, evaluation.hook, experimentId);
try {
await addNotification({
recipientWallet: wallet,
type: "system",
await createNotification(wallet, "winback_reactivation", {
title: evaluation.title,
body: evaluation.body,
data: {
cohort: evaluation.cohort,
hook: evaluation.hook,
experimentId,
},
cohort: evaluation.cohort,
hook: evaluation.hook,
experimentId,
});
dispatched = true;
} catch {
Expand Down
12 changes: 3 additions & 9 deletions app/api/profile/avatar/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,13 +259,7 @@ export async function verifyAvatarOwnershipSignature(
imageUrl: string,
signatureBase64: string,
): Promise<boolean> {
try {
const { Keypair } = await import("@stellar/stellar-sdk")
const { SIGNATURE_PREFIX, sha256Hex } = await import("@/lib/viewer-signature")
const message = `phase-avatar:v1:${await sha256Hex(JSON.stringify({ wallet, imageUrl }))}`
const data = new TextEncoder().encode(SIGNATURE_PREFIX + message)
return Keypair.fromPublicKey(wallet).verify(data, Buffer.from(signatureBase64, "base64"))
} catch {
return false
}
const { sha256Hex, verifySep53Signature } = await import("@/lib/viewer-signature")
const message = `phase-avatar:v1:${await sha256Hex(JSON.stringify({ wallet, imageUrl }))}`
return verifySep53Signature(wallet, message, signatureBase64)
}
31 changes: 31 additions & 0 deletions app/api/versions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server"
import { isFeatureEnabled } from "@/lib/feature-flags"
import {
API_CHANGELOG,
API_VERSIONS,
buildOpenApiDocument,
currentApiVersion,
ROUTE_DEPRECATIONS,
} from "@/lib/api-versioning"

export const dynamic = "force-dynamic"

/** phase-api-versioning: machine-readable versions, deprecations, changelog and OpenAPI contract. */
export async function GET(request: NextRequest) {
if (!isFeatureEnabled("phase-api-versioning")) {
return NextResponse.json({ error: "Feature phase-api-versioning disabled" }, { status: 404 })
}
const format = request.nextUrl.searchParams.get("format")
if (format === "openapi") {
return NextResponse.json(buildOpenApiDocument())
}
if (format && format !== "summary") {
return NextResponse.json({ error: "format must be summary or openapi" }, { status: 400 })
}
return NextResponse.json({
current: currentApiVersion(),
versions: API_VERSIONS,
deprecations: ROUTE_DEPRECATIONS,
changelog: API_CHANGELOG,
})
}
1 change: 0 additions & 1 deletion components/chamber/artifact-viewer.tsx

This file was deleted.

25 changes: 25 additions & 0 deletions components/chamber/catalog-thumb.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"use client"

// Extracted verbatim from components/fusion-chamber.tsx (refactor #33 imported it but never committed the file).

import { useState } from "react"
import { IpfsDisplayImg } from "@/components/ipfs-display-img"

export function ChamberCatalogThumb({ collectionId, uri }: { collectionId: number; uri: string }) {
const [broken, setBroken] = useState(false)
if (!uri.trim() || broken) {
return (
<span className="px-1 text-center text-[7px] font-bold uppercase leading-tight text-cyan-500/80">
#{collectionId}
</span>
)
}
return (
<IpfsDisplayImg
uri={uri}
className="h-full w-full object-cover"
loading="lazy"
onExhausted={() => setBroken(true)}
/>
)
}
1 change: 0 additions & 1 deletion components/chamber/certificate-exporter.tsx

This file was deleted.

11 changes: 11 additions & 0 deletions components/chamber/chamber-chrome.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Extracted verbatim from components/fusion-chamber.tsx (refactor #33 imported it but never committed the file).

/** Same zinc / violet chrome as `/dashboard` (Phase Market). */
export const chamberChromeNav =
"inline-flex min-h-[36px] items-center rounded-sm border border-zinc-700 bg-zinc-900/60 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-zinc-400 transition-colors hover:border-violet-500/50 hover:text-violet-300"
export const chamberChromeNavHere =
"inline-flex min-h-[36px] items-center rounded-sm border border-violet-600/50 bg-violet-950/50 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-violet-300"
export const chamberChromePrimaryBtn =
"flex min-h-[46px] w-full items-center justify-center bg-gradient-to-r from-violet-600 to-cyan-500 py-3 text-center text-[11px] font-bold uppercase tracking-wide text-white shadow-[0_0_18px_rgba(139,92,246,0.35)] transition-opacity hover:opacity-90 disabled:pointer-events-none"
export const chamberChromeRefreshBtn =
"flex items-center gap-1.5 rounded-sm border border-zinc-700 bg-zinc-900/60 px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest text-zinc-400 transition-colors hover:border-violet-500/50 hover:text-violet-300 disabled:opacity-40"
1 change: 0 additions & 1 deletion components/chamber/energy-gauge-display.tsx

This file was deleted.

36 changes: 36 additions & 0 deletions components/chamber/log-stream.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"use client"

// Extracted verbatim from components/fusion-chamber.tsx (refactor #33 imported it but never committed the file).

import type { RefObject } from "react"
import { cn } from "@/lib/utils"

export function ChamberLogStream({
lines,
endRef,
}: {
lines: { id: number; text: string }[]
endRef: RefObject<HTMLDivElement | null>
}) {
return (
<>
{lines.map((l, i) => {
const fault = /FAIL|Failed|ERROR|FAULT|ABORT|REJECT|denied|timeout|sequence|desync/i.test(l.text)
return (
<div
key={l.id}
className={cn(
!fault &&
"tactical-log-line mb-2 border-l-2 border-[#39ff14]/35 pl-2.5 font-mono text-[11px] leading-relaxed tracking-wide text-[#7df5d8] sm:text-xs sm:leading-relaxed",
fault && "tactical-log-emergency mb-2 font-mono text-[11px] leading-relaxed sm:text-xs",
)}
style={!fault ? { animationDelay: `${Math.min(i, 8) * 65}ms` } : undefined}
>
<span className="break-words whitespace-pre-wrap">{l.text}</span>
</div>
)
})}
<div ref={endRef} />
</>
)
}
45 changes: 45 additions & 0 deletions components/chamber/phaser-liq-token-link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"use client"

// Extracted verbatim from components/fusion-chamber.tsx (refactor #33 imported it but never committed the file).

import { TokenIcon } from "@/components/token-icon"
import { playTacticalUiClick } from "@/lib/tactical-ui-click"
import { cn } from "@/lib/utils"

/** Enlace al asset PHASELQ en Stellar Expert (icono + símbolo). */
export function PhaserLiqTokenLink({
href,
symbol,
iconClassName,
className,
variant = "default",
}: {
href: string
symbol: string
iconClassName?: string
className?: string
variant?: "default" | "violet"
}) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
title={href}
onClick={(e) => {
e.stopPropagation()
playTacticalUiClick()
}}
className={cn(
"inline-flex items-center gap-1.5 rounded-sm border border-transparent font-mono tracking-normal underline-offset-[3px] transition-colors hover:underline focus:outline-none",
variant === "violet"
? "text-violet-200/95 hover:border-violet-500/45 hover:bg-violet-950/35 hover:text-violet-50 focus-visible:ring-2 focus-visible:ring-violet-400/55"
: "text-cyan-400/95 hover:border-cyan-500/45 hover:bg-cyan-950/35 hover:text-cyan-50 focus-visible:ring-2 focus-visible:ring-cyan-400/60",
className,
)}
>
<TokenIcon className={cn("shrink-0", iconClassName ?? "h-4 w-4")} />
{symbol}
</a>
)
}
1 change: 0 additions & 1 deletion components/chamber/settlement-wizard.tsx

This file was deleted.

1 change: 0 additions & 1 deletion components/chamber/terminal-header.tsx

This file was deleted.

Loading