From 8a361e5a7717f6aed19b70b8daec15ba218a0b08 Mon Sep 17 00:00:00 2001
From: Cyber Preacher <72062250+Cyber-preacher@users.noreply.github.com>
Date: Tue, 5 May 2026 21:38:43 +0400
Subject: [PATCH] Complete Phase 85 web bug hunt
---
src/lib/addressIdentity.ts | 60 +++++++++
src/lib/proposalVetoUi.ts | 9 ++
src/pages/chambers/Chamber.tsx | 5 +-
src/pages/factions/Faction.tsx | 17 +--
src/pages/factions/FactionChannel.tsx | 11 +-
src/pages/factions/FactionInitiative.tsx | 23 ++--
.../factions/FactionInitiativeCreate.tsx | 8 +-
src/pages/factions/FactionThreadCreate.tsx | 9 +-
src/pages/feed/Feed.tsx | 12 +-
src/pages/human-nodes/HumanNode.tsx | 9 +-
src/pages/proposals/ProposalChamber.tsx | 8 +-
src/pages/proposals/ProposalChamberVeto.tsx | 8 +-
src/pages/proposals/ProposalCitizenVeto.tsx | 17 ++-
src/pages/proposals/ProposalFormation.tsx | 9 +-
src/pages/proposals/ProposalPP.tsx | 8 +-
src/pages/proposals/ProposalReferendum.tsx | 8 +-
tests/api/cross-repo-contract.test.js | 122 ++++++++++++++++++
tests/unit/address-identity.test.ts | 21 +++
tests/unit/feed-urgent.test.ts | 27 ++++
tests/unit/proposal-veto-ui.test.ts | 28 ++++
20 files changed, 343 insertions(+), 76 deletions(-)
create mode 100644 src/lib/addressIdentity.ts
create mode 100644 src/lib/proposalVetoUi.ts
create mode 100644 tests/api/cross-repo-contract.test.js
create mode 100644 tests/unit/address-identity.test.ts
create mode 100644 tests/unit/feed-urgent.test.ts
create mode 100644 tests/unit/proposal-veto-ui.test.ts
diff --git a/src/lib/addressIdentity.ts b/src/lib/addressIdentity.ts
new file mode 100644
index 0000000..bedb8b4
--- /dev/null
+++ b/src/lib/addressIdentity.ts
@@ -0,0 +1,60 @@
+const BASE58_ALPHABET =
+ "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
+const BASE58_MAP = new Map(
+ [...BASE58_ALPHABET].map((char, index) => [char, index]),
+);
+
+function bytesToHex(bytes: Uint8Array): string {
+ return `0x${[...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
+}
+
+function decodeBase58(value: string): Uint8Array | null {
+ let bytes = [0];
+ for (const char of value) {
+ const value = BASE58_MAP.get(char);
+ if (value === undefined) return null;
+ let carry = value;
+ for (let i = 0; i < bytes.length; i += 1) {
+ const next = bytes[i] * 58 + carry;
+ bytes[i] = next & 0xff;
+ carry = next >> 8;
+ }
+ while (carry > 0) {
+ bytes.push(carry & 0xff);
+ carry >>= 8;
+ }
+ }
+
+ for (const char of value) {
+ if (char !== "1") break;
+ bytes.push(0);
+ }
+
+ return new Uint8Array(bytes.reverse());
+}
+
+function ss58PublicKeyHex(address: string): string | null {
+ const decoded = decodeBase58(address);
+ if (!decoded) return null;
+ const prefixLength = decoded[0] < 64 ? 1 : decoded[0] < 128 ? 2 : 0;
+ if (prefixLength === 0) return null;
+ const keyStart = prefixLength;
+ const keyEnd = keyStart + 32;
+ if (decoded.length < keyEnd + 1) return null;
+ return bytesToHex(decoded.slice(keyStart, keyEnd));
+}
+
+export function addressIdentityKey(address: string | null | undefined): string {
+ const normalized = (address ?? "").trim();
+ if (!normalized) return "";
+ return ss58PublicKeyHex(normalized) ?? normalized.toLowerCase();
+}
+
+export function addressesReferToSameIdentity(
+ left: string | null | undefined,
+ right: string | null | undefined,
+): boolean {
+ const leftKey = addressIdentityKey(left);
+ const rightKey = addressIdentityKey(right);
+ return Boolean(leftKey && rightKey && leftKey === rightKey);
+}
diff --git a/src/lib/proposalVetoUi.ts b/src/lib/proposalVetoUi.ts
new file mode 100644
index 0000000..47bf124
--- /dev/null
+++ b/src/lib/proposalVetoUi.ts
@@ -0,0 +1,9 @@
+export function calculateCitizenVetoSupportPercent(input: {
+ vetoVotes: number;
+ eligibleCitizens: number;
+}): number {
+ const eligibleCitizens = Math.max(0, input.eligibleCitizens);
+ if (eligibleCitizens === 0) return 0;
+ const vetoVotes = Math.max(0, input.vetoVotes);
+ return Math.round((vetoVotes / eligibleCitizens) * 100);
+}
diff --git a/src/pages/chambers/Chamber.tsx b/src/pages/chambers/Chamber.tsx
index 3d1bd24..134bcc5 100644
--- a/src/pages/chambers/Chamber.tsx
+++ b/src/pages/chambers/Chamber.tsx
@@ -47,6 +47,7 @@ import { formatDate, formatDateTime } from "@/lib/dateTime";
import { formatLoadError } from "@/lib/errorFormatting";
import { NoDataYetBar } from "@/components/NoDataYetBar";
import { useAuth } from "@/app/auth/AuthContext";
+import { addressesReferToSameIdentity } from "@/lib/addressIdentity";
const Chamber: React.FC = () => {
const { id } = useParams();
@@ -234,7 +235,9 @@ const Chamber: React.FC = () => {
const isMember = useMemo(() => {
if (!address || !data) return false;
- return data.governors.some((gov) => gov.id === address);
+ return data.governors.some((gov) =>
+ addressesReferToSameIdentity(gov.id, address),
+ );
}, [address, data]);
const canWrite = useMemo(() => {
diff --git a/src/pages/factions/Faction.tsx b/src/pages/factions/Faction.tsx
index edeaf30..ffae931 100644
--- a/src/pages/factions/Faction.tsx
+++ b/src/pages/factions/Faction.tsx
@@ -31,14 +31,11 @@ import {
apiMe,
getApiErrorPayload,
} from "@/lib/apiClient";
+import { addressesReferToSameIdentity } from "@/lib/addressIdentity";
import { formatDateTime } from "@/lib/dateTime";
import { formatLoadError } from "@/lib/errorFormatting";
import type { FactionDto } from "@/types/api";
-function normalizeAddress(value: string): string {
- return value.trim().toLowerCase();
-}
-
const Faction: React.FC = () => {
const { id } = useParams();
const [searchParams] = useSearchParams();
@@ -96,10 +93,8 @@ const Faction: React.FC = () => {
const viewerMembership = useMemo(() => {
if (!viewerAddress) return null;
- return memberships.find(
- (membership) =>
- normalizeAddress(membership.address) ===
- normalizeAddress(viewerAddress),
+ return memberships.find((membership) =>
+ addressesReferToSameIdentity(membership.address, viewerAddress),
);
}, [memberships, viewerAddress]);
@@ -389,8 +384,10 @@ const Faction: React.FC = () => {
.map((membership) => {
const isSelf =
viewerAddress !== null &&
- normalizeAddress(viewerAddress) ===
- normalizeAddress(membership.address);
+ addressesReferToSameIdentity(
+ viewerAddress,
+ membership.address,
+ );
return (
{
const { id, channelId, threadId } = useParams();
const [faction, setFaction] = useState(null);
@@ -64,10 +61,8 @@ const FactionChannel: React.FC = () => {
const viewerMembership = useMemo(() => {
if (!viewerAddress) return null;
- return memberships.find(
- (membership) =>
- normalizeAddress(membership.address) ===
- normalizeAddress(viewerAddress),
+ return memberships.find((membership) =>
+ addressesReferToSameIdentity(membership.address, viewerAddress),
);
}, [memberships, viewerAddress]);
diff --git a/src/pages/factions/FactionInitiative.tsx b/src/pages/factions/FactionInitiative.tsx
index 3eef7ec..4439267 100644
--- a/src/pages/factions/FactionInitiative.tsx
+++ b/src/pages/factions/FactionInitiative.tsx
@@ -17,14 +17,11 @@ import {
apiMe,
getApiErrorPayload,
} from "@/lib/apiClient";
+import { addressesReferToSameIdentity } from "@/lib/addressIdentity";
import { formatDateTime } from "@/lib/dateTime";
import { formatLoadError } from "@/lib/errorFormatting";
import type { FactionDto } from "@/types/api";
-function normalizeAddress(value: string): string {
- return value.trim().toLowerCase();
-}
-
const FactionInitiative: React.FC = () => {
const { id, initiativeId } = useParams();
const [faction, setFaction] = useState(null);
@@ -60,10 +57,8 @@ const FactionInitiative: React.FC = () => {
const viewerMembership = useMemo(() => {
if (!viewerAddress) return null;
- return memberships.find(
- (membership) =>
- normalizeAddress(membership.address) ===
- normalizeAddress(viewerAddress),
+ return memberships.find((membership) =>
+ addressesReferToSameIdentity(membership.address, viewerAddress),
);
}, [memberships, viewerAddress]);
@@ -73,10 +68,12 @@ const FactionInitiative: React.FC = () => {
viewerMembership?.role === "steward";
const initiatives = useMemo(() => {
- const viewer = normalizeAddress(viewerAddress ?? "");
return initiativesRaw.filter((initiative) => {
if (initiative.status !== "draft") return true;
- return normalizeAddress(initiative.ownerAddress) === viewer;
+ return addressesReferToSameIdentity(
+ initiative.ownerAddress,
+ viewerAddress,
+ );
});
}, [initiativesRaw, viewerAddress]);
@@ -87,9 +84,9 @@ const FactionInitiative: React.FC = () => {
const canEditActiveInitiative = useMemo(() => {
if (!activeInitiative) return false;
if (canModerate) return true;
- return (
- normalizeAddress(activeInitiative.ownerAddress) ===
- normalizeAddress(viewerAddress ?? "")
+ return addressesReferToSameIdentity(
+ activeInitiative.ownerAddress,
+ viewerAddress,
);
}, [activeInitiative, canModerate, viewerAddress]);
diff --git a/src/pages/factions/FactionInitiativeCreate.tsx b/src/pages/factions/FactionInitiativeCreate.tsx
index cbcd4da..0f8c0ec 100644
--- a/src/pages/factions/FactionInitiativeCreate.tsx
+++ b/src/pages/factions/FactionInitiativeCreate.tsx
@@ -15,13 +15,10 @@ import {
apiMe,
getApiErrorPayload,
} from "@/lib/apiClient";
+import { addressesReferToSameIdentity } from "@/lib/addressIdentity";
import { formatLoadError } from "@/lib/errorFormatting";
import type { FactionDto } from "@/types/api";
-function normalizeAddress(value: string): string {
- return value.trim().toLowerCase();
-}
-
const FactionInitiativeCreate: React.FC = () => {
const { id } = useParams();
const navigate = useNavigate();
@@ -62,8 +59,7 @@ const FactionInitiativeCreate: React.FC = () => {
return (faction.memberships ?? []).some(
(membership) =>
membership.isActive &&
- normalizeAddress(membership.address) ===
- normalizeAddress(viewerAddress),
+ addressesReferToSameIdentity(membership.address, viewerAddress),
);
}, [faction, viewerAddress]);
diff --git a/src/pages/factions/FactionThreadCreate.tsx b/src/pages/factions/FactionThreadCreate.tsx
index 3691d33..9e5e6ec 100644
--- a/src/pages/factions/FactionThreadCreate.tsx
+++ b/src/pages/factions/FactionThreadCreate.tsx
@@ -15,13 +15,10 @@ import {
apiMe,
getApiErrorPayload,
} from "@/lib/apiClient";
+import { addressesReferToSameIdentity } from "@/lib/addressIdentity";
import { formatLoadError } from "@/lib/errorFormatting";
import type { FactionDto } from "@/types/api";
-function normalizeAddress(value: string): string {
- return value.trim().toLowerCase();
-}
-
const FactionThreadCreate: React.FC = () => {
const { id, channelId } = useParams();
const navigate = useNavigate();
@@ -61,8 +58,8 @@ const FactionThreadCreate: React.FC = () => {
return (
(faction.memberships ?? []).find(
(membership) =>
- normalizeAddress(membership.address) ===
- normalizeAddress(viewerAddress) && membership.isActive,
+ addressesReferToSameIdentity(membership.address, viewerAddress) &&
+ membership.isActive,
) ?? null
);
}, [faction, viewerAddress]);
diff --git a/src/pages/feed/Feed.tsx b/src/pages/feed/Feed.tsx
index 27dc339..372b71d 100644
--- a/src/pages/feed/Feed.tsx
+++ b/src/pages/feed/Feed.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
+import { addressesReferToSameIdentity } from "@/lib/addressIdentity";
import { useAuth } from "@/app/auth/AuthContext";
import { Button } from "@/components/primitives/button";
import { PageHint } from "@/components/PageHint";
@@ -110,18 +111,17 @@ const urgentEntityKey = (item: FeedItemDto) => {
return `id:${item.id}`;
};
-const isUrgentItemInteractable = (
+export const isUrgentItemInteractable = (
item: FeedItemDto,
isGovernorActive: boolean,
viewerAddress?: string,
) => {
if (item.actionable !== true) return false;
if (item.stage === "build") {
- const viewer = viewerAddress?.trim().toLowerCase();
- const proposer = (item.proposerId ?? item.proposer ?? "")
- .trim()
- .toLowerCase();
- return Boolean(viewer && proposer && viewer === proposer);
+ return addressesReferToSameIdentity(
+ viewerAddress,
+ item.proposerId ?? item.proposer,
+ );
}
if ((item.stage === "pool" || item.stage === "vote") && !isGovernorActive) {
if (item.href?.includes("/referendum")) return true;
diff --git a/src/pages/human-nodes/HumanNode.tsx b/src/pages/human-nodes/HumanNode.tsx
index 0d15c3e..6c5fc5b 100644
--- a/src/pages/human-nodes/HumanNode.tsx
+++ b/src/pages/human-nodes/HumanNode.tsx
@@ -21,6 +21,7 @@ import {
apiHuman,
apiMyGovernance,
} from "@/lib/apiClient";
+import { addressesReferToSameIdentity } from "@/lib/addressIdentity";
import { formatLoadError } from "@/lib/errorFormatting";
import type {
GetMyGovernanceResponse,
@@ -251,7 +252,7 @@ const HumanNode: React.FC = () => {
);
const showShortBadge = !isAddressName && !isGenericName;
- const isSelfProfile = Boolean(auth.address && profile.id === auth.address);
+ const isSelfProfile = addressesReferToSameIdentity(auth.address, profile.id);
const handleDelegateHere = async (chamberId: string) => {
if (!id) return;
@@ -437,8 +438,10 @@ const HumanNode: React.FC = () => {
{delegationCards.map((item) => {
const viewerItem =
viewerDelegationByChamber.get(item.chamberId) ?? null;
- const viewerAlreadyDelegatesHere =
- viewerItem?.delegateeAddress === profile.id;
+ const viewerAlreadyDelegatesHere = addressesReferToSameIdentity(
+ viewerItem?.delegateeAddress,
+ profile.id,
+ );
const canManage =
!isSelfProfile &&
manageableChambers.some(
diff --git a/src/pages/proposals/ProposalChamber.tsx b/src/pages/proposals/ProposalChamber.tsx
index bd4d303..f3e126e 100644
--- a/src/pages/proposals/ProposalChamber.tsx
+++ b/src/pages/proposals/ProposalChamber.tsx
@@ -20,6 +20,7 @@ import {
apiProposalChamberPage,
apiProposalTimeline,
} from "@/lib/apiClient";
+import { addressesReferToSameIdentity } from "@/lib/addressIdentity";
import { formatLoadError } from "@/lib/errorFormatting";
import { formatDateTime } from "@/lib/dateTime";
import type {
@@ -161,9 +162,10 @@ const ProposalChamber: React.FC = () => {
? proposal.milestoneIndex
: null;
const referendumVote = proposal.voteKind === "referendum";
- const viewerIsProposer =
- auth.address?.trim().toLowerCase() ===
- proposal.proposerId.trim().toLowerCase();
+ const viewerIsProposer = addressesReferToSameIdentity(
+ auth.address,
+ proposal.proposerId,
+ );
const scoreLabel =
proposal.scoreLabel === "MM" || milestoneVoteIndex !== null
? "MM"
diff --git a/src/pages/proposals/ProposalChamberVeto.tsx b/src/pages/proposals/ProposalChamberVeto.tsx
index 7a2ad41..3580cf8 100644
--- a/src/pages/proposals/ProposalChamberVeto.tsx
+++ b/src/pages/proposals/ProposalChamberVeto.tsx
@@ -15,6 +15,7 @@ import {
apiProposalChamberVetoPage,
apiProposalTimeline,
} from "@/lib/apiClient";
+import { addressesReferToSameIdentity } from "@/lib/addressIdentity";
import { formatDateTime } from "@/lib/dateTime";
import { formatLoadError } from "@/lib/errorFormatting";
import type {
@@ -116,9 +117,10 @@ const ProposalChamberVeto: React.FC = () => {
);
}
- const viewerIsProposer =
- auth.address?.trim().toLowerCase() ===
- proposal.proposerId.trim().toLowerCase();
+ const viewerIsProposer = addressesReferToSameIdentity(
+ auth.address,
+ proposal.proposerId,
+ );
const stageLinks = id
? {
vote: proposal.voteRoute,
diff --git a/src/pages/proposals/ProposalCitizenVeto.tsx b/src/pages/proposals/ProposalCitizenVeto.tsx
index 9a375f2..af621a3 100644
--- a/src/pages/proposals/ProposalCitizenVeto.tsx
+++ b/src/pages/proposals/ProposalCitizenVeto.tsx
@@ -15,7 +15,9 @@ import {
apiProposalCitizenVetoPage,
apiProposalTimeline,
} from "@/lib/apiClient";
+import { addressesReferToSameIdentity } from "@/lib/addressIdentity";
import { formatLoadError } from "@/lib/errorFormatting";
+import { calculateCitizenVetoSupportPercent } from "@/lib/proposalVetoUi";
import type {
CitizenVetoProposalPageDto,
ProposalTimelineItemDto,
@@ -120,13 +122,14 @@ const ProposalCitizenVeto: React.FC = () => {
proposal.eligibleCitizens > 0
? Math.round((castVotes / proposal.eligibleCitizens) * 100)
: 0;
- const vetoPercent =
- proposal.eligibleCitizens > 0
- ? Math.round((proposal.votes.veto / proposal.eligibleCitizens) * 100)
- : 0;
- const viewerIsProposer =
- auth.address?.trim().toLowerCase() ===
- proposal.proposerId.trim().toLowerCase();
+ const vetoPercent = calculateCitizenVetoSupportPercent({
+ vetoVotes: proposal.votes.veto,
+ eligibleCitizens: proposal.eligibleCitizens,
+ });
+ const viewerIsProposer = addressesReferToSameIdentity(
+ auth.address,
+ proposal.proposerId,
+ );
const stageLinks = id
? {
vote: proposal.voteRoute,
diff --git a/src/pages/proposals/ProposalFormation.tsx b/src/pages/proposals/ProposalFormation.tsx
index 35048b4..d4876d0 100644
--- a/src/pages/proposals/ProposalFormation.tsx
+++ b/src/pages/proposals/ProposalFormation.tsx
@@ -16,6 +16,7 @@ import {
apiProposalFormationPage,
apiProposalTimeline,
} from "@/lib/apiClient";
+import { addressesReferToSameIdentity } from "@/lib/addressIdentity";
import { formatLoadError } from "@/lib/errorFormatting";
import { useAuth } from "@/app/auth/AuthContext";
import type {
@@ -123,10 +124,10 @@ const ProposalFormation: React.FC = () => {
project.nextMilestoneIndex ??
(milestones.total > 0 ? milestones.filled + 1 : undefined);
const pendingMilestone = project.pendingMilestoneIndex ?? undefined;
- const viewerAddress = auth.address?.trim().toLowerCase();
- const proposerAddress = project.proposer.trim().toLowerCase();
- const isProposerViewer =
- Boolean(viewerAddress) && viewerAddress === proposerAddress;
+ const isProposerViewer = addressesReferToSameIdentity(
+ auth.address,
+ project.proposer,
+ );
const canJoinProject =
project.projectState !== "ready_to_finish" &&
project.projectState !== "completed" &&
diff --git a/src/pages/proposals/ProposalPP.tsx b/src/pages/proposals/ProposalPP.tsx
index 426a61a..fcedbfa 100644
--- a/src/pages/proposals/ProposalPP.tsx
+++ b/src/pages/proposals/ProposalPP.tsx
@@ -19,6 +19,7 @@ import {
apiProposalTimeline,
getApiErrorPayload,
} from "@/lib/apiClient";
+import { addressesReferToSameIdentity } from "@/lib/addressIdentity";
import { formatLoadError } from "@/lib/errorFormatting";
import type { PoolProposalPageDto, ProposalTimelineItemDto } from "@/types/api";
import { useAuth } from "@/app/auth/AuthContext";
@@ -123,9 +124,10 @@ const ProposalPP: React.FC = () => {
? proposal.teamSlots.split("/").map((v) => Number(v.trim()))
: [0, 0];
const openSlots = Math.max(totalSlots - filledSlots, 0);
- const viewerIsProposer =
- auth.address?.trim().toLowerCase() ===
- proposal.proposerId.trim().toLowerCase();
+ const viewerIsProposer = addressesReferToSameIdentity(
+ auth.address,
+ proposal.proposerId,
+ );
const formationSummaryStats = proposal.formationEligible
? [
{ label: "Budget ask", value: proposal.budget },
diff --git a/src/pages/proposals/ProposalReferendum.tsx b/src/pages/proposals/ProposalReferendum.tsx
index 8fef22e..a3fcf86 100644
--- a/src/pages/proposals/ProposalReferendum.tsx
+++ b/src/pages/proposals/ProposalReferendum.tsx
@@ -17,6 +17,7 @@ import {
apiProposalTimeline,
apiReferendumVote,
} from "@/lib/apiClient";
+import { addressesReferToSameIdentity } from "@/lib/addressIdentity";
import { formatLoadError } from "@/lib/errorFormatting";
import type {
ChamberProposalPageDto,
@@ -137,9 +138,10 @@ const ProposalReferendum: React.FC = () => {
const yesPercentOfQuorum =
engaged > 0 ? Math.round((yesTotal / engaged) * 100) : 0;
const passingNeededPercent = 66.6;
- const viewerIsProposer =
- auth.address?.trim().toLowerCase() ===
- proposal.proposerId.trim().toLowerCase();
+ const viewerIsProposer = addressesReferToSameIdentity(
+ auth.address,
+ proposal.proposerId,
+ );
const [filledSlots, totalSlots] = proposal.formationEligible
? proposal.teamSlots.split("/").map((v) => Number(v.trim()))
diff --git a/tests/api/cross-repo-contract.test.js b/tests/api/cross-repo-contract.test.js
new file mode 100644
index 0000000..d944fcb
--- /dev/null
+++ b/tests/api/cross-repo-contract.test.js
@@ -0,0 +1,122 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { test } from "@rstest/core";
+
+const webRoot = process.cwd();
+const workspaceRoot = resolve(webRoot, "..");
+const serverRoot = resolve(workspaceRoot, "vortex-simulator-server");
+
+function readServer(path) {
+ return readFileSync(resolve(serverRoot, path), "utf8");
+}
+
+function readWeb(path) {
+ return readFileSync(resolve(webRoot, path), "utf8");
+}
+
+function unique(values) {
+ return [...new Set(values)].sort((a, b) => a.localeCompare(b));
+}
+
+function extractServerCommandTypes() {
+ const source = readServer("api/commandSchemas.ts");
+ return new Set(
+ [...source.matchAll(/type:\s*z\.literal\("([^"]+)"\)/g)].map(
+ (match) => match[1],
+ ),
+ );
+}
+
+function extractClientCommandTypes() {
+ const source = readWeb("src/lib/apiClient.ts");
+ return unique(
+ [...source.matchAll(/type:\s*"([^"]+)"/g)].map((match) => match[1]),
+ ).filter((type) => type.includes("."));
+}
+
+const resourceFilesByRouter = {
+ admin: "api/resources/admin.ts",
+ auth: "api/resources/auth.ts",
+ chambers: "api/resources/chambers.ts",
+ clock: "api/resources/clock.ts",
+ cm: "api/resources/cm.ts",
+ command: "api/resources/command.ts",
+ courts: "api/resources/courts.ts",
+ factions: "api/resources/factions.ts",
+ feed: "api/resources/feed.ts",
+ formation: "api/resources/formation.ts",
+ gate: "api/resources/gate.ts",
+ health: "api/resources/health.ts",
+ humans: "api/resources/humans.ts",
+ invision: "api/resources/invision.ts",
+ myGovernance: "api/resources/myGovernance.ts",
+ proposals: "api/resources/proposals.ts",
+};
+
+function normalizeRoute(path) {
+ const withoutQuery = path.replace(/\?.*$/, "");
+ const withoutTemplateQuery = withoutQuery.replace(
+ /\$\{qs(?:\.toString\(\))?\}/g,
+ "",
+ );
+ const withParams = withoutTemplateQuery
+ .replace(/\$\{[^}]+\}/g, ":param")
+ .replace(/:[A-Za-z0-9_]+/g, ":param");
+ return withParams.replace(/\/$/, "") || "/";
+}
+
+function joinRoute(prefix, routePath) {
+ const normalizedPrefix = prefix.replace(/\/$/, "");
+ if (routePath === "/") return normalizedPrefix;
+ return `${normalizedPrefix}${routePath.startsWith("/") ? routePath : `/${routePath}`}`;
+}
+
+function extractServerRoutes() {
+ const apiSource = readServer("api/api.ts");
+ const routes = new Set();
+
+ for (const match of apiSource.matchAll(
+ /api\.route\("([^"]+)",\s*([A-Za-z0-9_]+)\)/g,
+ )) {
+ const [, prefix, routerName] = match;
+ const file = resourceFilesByRouter[routerName];
+ assert.ok(file, `Missing route parser mapping for router ${routerName}`);
+ const source = readServer(file);
+ const routeRegex = new RegExp(`${routerName}\\.\\w+\\("([^"]+)"`, "g");
+ for (const routeMatch of source.matchAll(routeRegex)) {
+ routes.add(normalizeRoute(joinRoute(`/api${prefix}`, routeMatch[1])));
+ }
+ }
+
+ for (const match of apiSource.matchAll(/api\.\w+\("([^"]+)"/g)) {
+ routes.add(normalizeRoute(`/api${match[1]}`));
+ }
+
+ return routes;
+}
+
+function extractClientRoutes() {
+ const source = readWeb("src/lib/apiClient.ts");
+ const routes = [];
+ const callRegex = /api(?:Get|Post)(?:<[^>]+>)?\(\s*([`'"])([\s\S]*?)\1/g;
+ for (const match of source.matchAll(callRegex)) {
+ const path = match[2].trim();
+ if (path.startsWith("/api/")) routes.push(normalizeRoute(path));
+ }
+ return unique(routes);
+}
+
+test("web command client only emits command types accepted by the server schema", () => {
+ const serverTypes = extractServerCommandTypes();
+ const clientTypes = extractClientCommandTypes();
+ const missing = clientTypes.filter((type) => !serverTypes.has(type));
+ assert.deepEqual(missing, []);
+});
+
+test("web API client only calls routes exposed by the server router", () => {
+ const serverRoutes = extractServerRoutes();
+ const clientRoutes = extractClientRoutes();
+ const missing = clientRoutes.filter((route) => !serverRoutes.has(route));
+ assert.deepEqual(missing, []);
+});
diff --git a/tests/unit/address-identity.test.ts b/tests/unit/address-identity.test.ts
new file mode 100644
index 0000000..cccd2b8
--- /dev/null
+++ b/tests/unit/address-identity.test.ts
@@ -0,0 +1,21 @@
+import { test, expect } from "@rstest/core";
+
+import {
+ addressIdentityKey,
+ addressesReferToSameIdentity,
+} from "../../src/lib/addressIdentity";
+
+const genericAddress = "5C62Ck4UrFPiBtoCmeSrgF7x9yv9mn38446dhCpsi2mLHiFT";
+const canonicalAddress = "hmnVXRhJsFLh5CbdxZNrn5Lu6FR2nDacxgSLrsVoyoW9ERXAP";
+
+test("addressIdentityKey resolves same-key SS58 encodings", () => {
+ expect(addressIdentityKey(genericAddress)).toBe(
+ addressIdentityKey(canonicalAddress),
+ );
+});
+
+test("addressesReferToSameIdentity falls back to case-insensitive string identity", () => {
+ expect(addressesReferToSameIdentity("0xAlice", "0xalice")).toBe(true);
+ expect(addressesReferToSameIdentity("0xAlice", "0xBob")).toBe(false);
+ expect(addressesReferToSameIdentity("", "0xBob")).toBe(false);
+});
diff --git a/tests/unit/feed-urgent.test.ts b/tests/unit/feed-urgent.test.ts
new file mode 100644
index 0000000..cdb74e3
--- /dev/null
+++ b/tests/unit/feed-urgent.test.ts
@@ -0,0 +1,27 @@
+import { test, expect } from "@rstest/core";
+
+import { isUrgentItemInteractable } from "../../src/pages/feed/Feed";
+import type { FeedItemDto } from "../../src/types/api";
+
+const genericAddress = "5C62Ck4UrFPiBtoCmeSrgF7x9yv9mn38446dhCpsi2mLHiFT";
+const canonicalAddress = "hmnVXRhJsFLh5CbdxZNrn5Lu6FR2nDacxgSLrsVoyoW9ERXAP";
+
+const buildItem: FeedItemDto = {
+ id: "feed:build",
+ title: "Build action",
+ meta: "Formation",
+ stage: "build",
+ summary: "Build action",
+ summaryPill: "Build",
+ timestamp: "2026-01-01T00:00:00.000Z",
+ href: "/app/proposals/build/formation",
+ actionable: true,
+ proposerId: canonicalAddress,
+};
+
+test("urgent build feed actionability resolves same-key proposer variants", () => {
+ expect(isUrgentItemInteractable(buildItem, false, genericAddress)).toBe(true);
+ expect(isUrgentItemInteractable(buildItem, false, "hmptest-other")).toBe(
+ false,
+ );
+});
diff --git a/tests/unit/proposal-veto-ui.test.ts b/tests/unit/proposal-veto-ui.test.ts
new file mode 100644
index 0000000..9bf82c7
--- /dev/null
+++ b/tests/unit/proposal-veto-ui.test.ts
@@ -0,0 +1,28 @@
+import { test, expect } from "@rstest/core";
+
+import { calculateCitizenVetoSupportPercent } from "../../src/lib/proposalVetoUi";
+
+test("citizen veto support percent uses eligible Citizens as denominator", () => {
+ expect(
+ calculateCitizenVetoSupportPercent({
+ vetoVotes: 1,
+ eligibleCitizens: 3,
+ }),
+ ).toBe(33);
+
+ expect(
+ calculateCitizenVetoSupportPercent({
+ vetoVotes: 2,
+ eligibleCitizens: 3,
+ }),
+ ).toBe(67);
+});
+
+test("citizen veto support percent handles an empty electorate", () => {
+ expect(
+ calculateCitizenVetoSupportPercent({
+ vetoVotes: 1,
+ eligibleCitizens: 0,
+ }),
+ ).toBe(0);
+});