diff --git a/app/routes.ts b/app/routes.ts
index 869684a..13e9af6 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -23,6 +23,14 @@ export default [
}),
]),
]),
+ ...prefix("governance", [
+ index("routes/governance.tsx"),
+ route("add-wasm", "routes/governanceAddWasm.tsx"),
+ route("add-contract", "routes/governanceAddContract.tsx"),
+ route("new-subregistry", "routes/governanceNewSubregistry.tsx"),
+ route("change-wasm-owner", "routes/governanceChangeWasmOwner.tsx"),
+ route("change-contract-owner", "routes/governanceChangeContractOwner.tsx"),
+ ]),
route("api/*", "routes/api.tsx"),
route("*", "routes/404.tsx"),
] satisfies RouteConfig
diff --git a/app/routes/governance.module.css b/app/routes/governance.module.css
new file mode 100644
index 0000000..aca60f7
--- /dev/null
+++ b/app/routes/governance.module.css
@@ -0,0 +1,88 @@
+/* ── Page header ─────────────────────────────────────── */
+
+.pageHeader {
+ background-color: var(--color-card);
+ border-bottom: 1px solid var(--color-border);
+ padding: 2.5rem 0;
+}
+
+.pageHeaderInner {
+ max-width: 56rem;
+ margin: 0 auto;
+ padding: 0 1.5rem;
+}
+
+.pageTitle {
+ font-family: var(--font-display);
+ font-size: 2rem;
+ font-weight: 500;
+ letter-spacing: -0.02em;
+ margin: 0 0 0.5rem;
+}
+
+.pageSub {
+ font-size: 1rem;
+ line-height: 1.6;
+ color: var(--color-muted-foreground);
+ margin: 0;
+ max-width: 42rem;
+}
+
+/* ── Main ────────────────────────────────────────────── */
+
+.main {
+ max-width: 56rem;
+ margin: 0 auto;
+ padding: 2rem 1.5rem 4rem;
+}
+
+.operations {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1.25rem;
+}
+
+@media (max-width: 600px) {
+ .operations {
+ grid-template-columns: 1fr;
+ }
+}
+
+.operationCard {
+ display: flex;
+ flex-direction: column;
+ padding: 1.5rem;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ text-decoration: none;
+ color: inherit;
+ background-color: var(--color-card);
+ transition: border-color var(--default-transition-duration)
+ var(--default-transition-timing-function);
+}
+
+.operationCard:hover {
+ border-color: var(--color-ring);
+}
+
+.operationTitle {
+ font-family: var(--font-display);
+ font-size: 1.125rem;
+ font-weight: 500;
+ margin: 0 0 0.625rem;
+ color: var(--color-foreground);
+}
+
+.operationDesc {
+ font-size: 0.9375rem;
+ line-height: 1.6;
+ color: var(--color-muted-foreground);
+ margin: 0 0 1.25rem;
+ flex: 1;
+}
+
+.operationLink {
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--color-link);
+}
diff --git a/app/routes/governance.tsx b/app/routes/governance.tsx
new file mode 100644
index 0000000..8dd172e
--- /dev/null
+++ b/app/routes/governance.tsx
@@ -0,0 +1,49 @@
+import { Link } from "react-router"
+import { type Route } from "./+types/governance"
+import styles from "./governance.module.css"
+import { GOVERNANCE_OPERATIONS } from "~/lib/governance"
+
+export function meta({}: Route.MetaArgs) {
+ return [
+ { title: "Governance — Stellar Registry" },
+ {
+ name: "description",
+ content:
+ "Submit governance proposals for the Stellar Registry root registry.",
+ },
+ ]
+}
+
+export default function GovernanceIndex() {
+ return (
+ <>
+
+
+
Governance
+
+ Tansu doesn’t yet have a UI for the specific proposals Stellar
+ Registry needs, so these forms fill the gap for standard governance
+ operations. Submissions are currently mocked — nothing is sent
+ anywhere yet.
+
+
+
+
+
+
+ {GOVERNANCE_OPERATIONS.map((operation) => (
+
+
{operation.title}
+
{operation.description}
+
Open form →
+
+ ))}
+
+
+ >
+ )
+}
diff --git a/app/routes/governanceAddContract.tsx b/app/routes/governanceAddContract.tsx
new file mode 100644
index 0000000..e3f282f
--- /dev/null
+++ b/app/routes/governanceAddContract.tsx
@@ -0,0 +1,42 @@
+import { data } from "react-router"
+import { type Route } from "./+types/governanceAddContract"
+import { GovernanceForm } from "~/components/governance-form"
+import {
+ getGovernanceOperation,
+ submitGovernanceProposal,
+ validateGovernanceFields,
+} from "~/lib/governance"
+
+const OPERATION_ID = "add-contract"
+
+export function meta({}: Route.MetaArgs) {
+ const operation = getGovernanceOperation(OPERATION_ID)!
+ return [
+ { title: `${operation.title} — Governance — Stellar Registry` },
+ { name: "description", content: operation.description },
+ ]
+}
+
+export async function action({ request }: Route.ActionArgs) {
+ const operation = getGovernanceOperation(OPERATION_ID)!
+ const formData = await request.formData()
+ const { values, errors } = validateGovernanceFields(operation, formData)
+
+ if (Object.keys(errors).length > 0) {
+ return data({ ok: false as const, errors }, { status: 400 })
+ }
+
+ const result = await submitGovernanceProposal(operation.id, values)
+ return data({ ok: true as const, result })
+}
+
+export default function GovernanceAddContract({
+ actionData,
+}: Route.ComponentProps) {
+ return (
+
+ )
+}
diff --git a/app/routes/governanceAddWasm.tsx b/app/routes/governanceAddWasm.tsx
new file mode 100644
index 0000000..5d3d724
--- /dev/null
+++ b/app/routes/governanceAddWasm.tsx
@@ -0,0 +1,42 @@
+import { data } from "react-router"
+import { type Route } from "./+types/governanceAddWasm"
+import { GovernanceForm } from "~/components/governance-form"
+import {
+ getGovernanceOperation,
+ submitGovernanceProposal,
+ validateGovernanceFields,
+} from "~/lib/governance"
+
+const OPERATION_ID = "add-wasm"
+
+export function meta({}: Route.MetaArgs) {
+ const operation = getGovernanceOperation(OPERATION_ID)!
+ return [
+ { title: `${operation.title} — Governance — Stellar Registry` },
+ { name: "description", content: operation.description },
+ ]
+}
+
+export async function action({ request }: Route.ActionArgs) {
+ const operation = getGovernanceOperation(OPERATION_ID)!
+ const formData = await request.formData()
+ const { values, errors } = validateGovernanceFields(operation, formData)
+
+ if (Object.keys(errors).length > 0) {
+ return data({ ok: false as const, errors }, { status: 400 })
+ }
+
+ const result = await submitGovernanceProposal(operation.id, values)
+ return data({ ok: true as const, result })
+}
+
+export default function GovernanceAddWasm({
+ actionData,
+}: Route.ComponentProps) {
+ return (
+
+ )
+}
diff --git a/app/routes/governanceChangeContractOwner.tsx b/app/routes/governanceChangeContractOwner.tsx
new file mode 100644
index 0000000..f697c8e
--- /dev/null
+++ b/app/routes/governanceChangeContractOwner.tsx
@@ -0,0 +1,42 @@
+import { data } from "react-router"
+import { type Route } from "./+types/governanceChangeContractOwner"
+import { GovernanceForm } from "~/components/governance-form"
+import {
+ getGovernanceOperation,
+ submitGovernanceProposal,
+ validateGovernanceFields,
+} from "~/lib/governance"
+
+const OPERATION_ID = "change-contract-owner"
+
+export function meta({}: Route.MetaArgs) {
+ const operation = getGovernanceOperation(OPERATION_ID)!
+ return [
+ { title: `${operation.title} — Governance — Stellar Registry` },
+ { name: "description", content: operation.description },
+ ]
+}
+
+export async function action({ request }: Route.ActionArgs) {
+ const operation = getGovernanceOperation(OPERATION_ID)!
+ const formData = await request.formData()
+ const { values, errors } = validateGovernanceFields(operation, formData)
+
+ if (Object.keys(errors).length > 0) {
+ return data({ ok: false as const, errors }, { status: 400 })
+ }
+
+ const result = await submitGovernanceProposal(operation.id, values)
+ return data({ ok: true as const, result })
+}
+
+export default function GovernanceChangeContractOwner({
+ actionData,
+}: Route.ComponentProps) {
+ return (
+
+ )
+}
diff --git a/app/routes/governanceChangeWasmOwner.tsx b/app/routes/governanceChangeWasmOwner.tsx
new file mode 100644
index 0000000..26da66d
--- /dev/null
+++ b/app/routes/governanceChangeWasmOwner.tsx
@@ -0,0 +1,42 @@
+import { data } from "react-router"
+import { type Route } from "./+types/governanceChangeWasmOwner"
+import { GovernanceForm } from "~/components/governance-form"
+import {
+ getGovernanceOperation,
+ submitGovernanceProposal,
+ validateGovernanceFields,
+} from "~/lib/governance"
+
+const OPERATION_ID = "change-wasm-owner"
+
+export function meta({}: Route.MetaArgs) {
+ const operation = getGovernanceOperation(OPERATION_ID)!
+ return [
+ { title: `${operation.title} — Governance — Stellar Registry` },
+ { name: "description", content: operation.description },
+ ]
+}
+
+export async function action({ request }: Route.ActionArgs) {
+ const operation = getGovernanceOperation(OPERATION_ID)!
+ const formData = await request.formData()
+ const { values, errors } = validateGovernanceFields(operation, formData)
+
+ if (Object.keys(errors).length > 0) {
+ return data({ ok: false as const, errors }, { status: 400 })
+ }
+
+ const result = await submitGovernanceProposal(operation.id, values)
+ return data({ ok: true as const, result })
+}
+
+export default function GovernanceChangeWasmOwner({
+ actionData,
+}: Route.ComponentProps) {
+ return (
+
+ )
+}
diff --git a/app/routes/governanceNewSubregistry.tsx b/app/routes/governanceNewSubregistry.tsx
new file mode 100644
index 0000000..b20f055
--- /dev/null
+++ b/app/routes/governanceNewSubregistry.tsx
@@ -0,0 +1,42 @@
+import { data } from "react-router"
+import { type Route } from "./+types/governanceNewSubregistry"
+import { GovernanceForm } from "~/components/governance-form"
+import {
+ getGovernanceOperation,
+ submitGovernanceProposal,
+ validateGovernanceFields,
+} from "~/lib/governance"
+
+const OPERATION_ID = "new-subregistry"
+
+export function meta({}: Route.MetaArgs) {
+ const operation = getGovernanceOperation(OPERATION_ID)!
+ return [
+ { title: `${operation.title} — Governance — Stellar Registry` },
+ { name: "description", content: operation.description },
+ ]
+}
+
+export async function action({ request }: Route.ActionArgs) {
+ const operation = getGovernanceOperation(OPERATION_ID)!
+ const formData = await request.formData()
+ const { values, errors } = validateGovernanceFields(operation, formData)
+
+ if (Object.keys(errors).length > 0) {
+ return data({ ok: false as const, errors }, { status: 400 })
+ }
+
+ const result = await submitGovernanceProposal(operation.id, values)
+ return data({ ok: true as const, result })
+}
+
+export default function GovernanceNewSubregistry({
+ actionData,
+}: Route.ComponentProps) {
+ return (
+
+ )
+}
diff --git a/workers/app.ts b/workers/app.ts
index e176ed4..3883312 100644
--- a/workers/app.ts
+++ b/workers/app.ts
@@ -3,6 +3,9 @@ import { createRequestHandler } from "react-router"
interface Env {
REGISTRY_API_URL: string
REGISTRY_NETWORK: string
+ TANSU_ID: string
+ REGISTRY_ID: string
+ MANAGER_ID: string
}
declare module "react-router" {
diff --git a/wrangler.jsonc b/wrangler.jsonc
index a508cda..3f379af 100644
--- a/wrangler.jsonc
+++ b/wrangler.jsonc
@@ -12,6 +12,9 @@
"vars": {
"REGISTRY_API_URL": "https://stellar-registry-testnet.fly.dev",
"REGISTRY_NETWORK": "testnet",
+ "TANSU_ID": "CBXKUSLQPVF35FYURR5C42BPYA5UOVDXX2ELKIM2CAJMCI6HXG2BHGZA",
+ "REGISTRY_ID": "CAF2CLQM5X7SRVHJB2WS5IK33D4CQFFZYCFKDYLR2SCQASFPM3YN7EW7",
+ "MANAGER_ID": "CAL4PZO2QDY2ELLLY6I2BUTOOZ4ZNSMZA77L3QOZKQN2XKV3Q6LR2VGI",
},
"env": {
@@ -23,6 +26,9 @@
// TODO: point to mainnet indexer once launched
"REGISTRY_API_URL": "https://stellar-registry-testnet.fly.dev",
"REGISTRY_NETWORK": "mainnet",
+ "TANSU_ID": "CBXKUSLQPVF35FYURR5C42BPYA5UOVDXX2ELKIM2CAJMCI6HXG2BHGZA",
+ "REGISTRY_ID": "CAF2CLQM5X7SRVHJB2WS5IK33D4CQFFZYCFKDYLR2SCQASFPM3YN7EW7",
+ "MANAGER_ID": "CAL4PZO2QDY2ELLLY6I2BUTOOZ4ZNSMZA77L3QOZKQN2XKV3Q6LR2VGI",
},
},
@@ -33,6 +39,9 @@
"vars": {
"REGISTRY_API_URL": "https://stellar-registry-testnet.fly.dev",
"REGISTRY_NETWORK": "testnet",
+ "TANSU_ID": "CBXKUSLQPVF35FYURR5C42BPYA5UOVDXX2ELKIM2CAJMCI6HXG2BHGZA",
+ "REGISTRY_ID": "CAF2CLQM5X7SRVHJB2WS5IK33D4CQFFZYCFKDYLR2SCQASFPM3YN7EW7",
+ "MANAGER_ID": "CAL4PZO2QDY2ELLLY6I2BUTOOZ4ZNSMZA77L3QOZKQN2XKV3Q6LR2VGI",
},
},
},