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
63 changes: 1 addition & 62 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,68 +13,7 @@ Humanode mainnet is used only as a read-only eligibility gate; all simulated gov
- Rsbuild
- Tailwind v4 (via PostCSS) + token-driven CSS (`src/styles/base.css`)
- Yarn (Node version: `.node-version`)
- API handlers in `functions/` + Postgres via Drizzle

## Getting Started

```bash
corepack enable
yarn install
yarn dev
```

Dev server: http://localhost:3000

Landing: http://localhost:3000/
App: http://localhost:3000/app

## Simulation API (local)

The UI reads from `/api/*` (API handlers). For local dev, run the API locally so the UI can reach it:

- One command: `yarn dev:full` (API on `:8788` + UI on `:3000` + `/api/*` proxy)
- Two terminals:
- Terminal 1: `yarn dev:api`
- Terminal 2: `yarn dev`

If only `yarn dev` runs, `/api/*` is not available and auth/gating/read pages will show an “API is not available” error.

### Production note (Pages env)

In production, API handlers use runtime env vars. If `DATABASE_URL` is not configured, the API falls back to an **ephemeral in-memory mode** (useful for quick demos, not durable). For a persistent public demo, set `DATABASE_URL` and run `yarn db:migrate` against that database.

### Backend docs

- Start here: `docs/README.md`
- Docs are grouped in: `docs/simulation/`, `docs/ops/`, `docs/paper/`
- Module map (paper → docs → code): `docs/simulation/vortex-simulation-modules.md`
- API contract: `docs/simulation/vortex-simulation-api-contract.md`
- Proposal wizard architecture: `docs/simulation/vortex-simulation-proposal-wizard-architecture.md`
- Local dev: `docs/simulation/vortex-simulation-local-dev.md`
- Scope and rules: `docs/simulation/vortex-simulation-scope-v1.md`, `docs/simulation/vortex-simulation-state-machines.md`
- Vortex 1.0 reference (working copy): `docs/paper/vortex-1.0-paper.md`

## Scripts

- `yarn dev` – start the dev server
- `yarn dev:api` – run the API handlers locally (Node runner)
- `yarn dev:full` – run UI + API together (recommended)
- `yarn build` – build the app
- `yarn test` – run API/unit tests
- `yarn prettier:check` / `yarn prettier:fix`

## Type checking

- Repo typecheck (UI + API handlers + DB seed builders): `yarn exec tsc --noEmit`

## Project Structure

- `src/app` – App shell, routes, sidebar
- `src/components` – shared UI (Hint, PageHint, SearchBar) and primitives under `primitives/`
- `src/data` – glossary (vortexopedia), page hints/tutorial content
- `src/pages` – feature pages (proposals, human-nodes, formations, chambers, factions, courts, feed, profile, invision, etc.)
- `src/styles` – base/global styles
- `functions/` – API handlers (`/api/*`) + shared server helpers
- API handlers in `api/` + Postgres via Drizzle
- `db/` – Drizzle schema + migrations + seed builders
- `scripts/` – DB seed/clear + local API runner
- `prolog/vortexopedia.pl` – Prolog glossary mirror
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
7 changes: 7 additions & 0 deletions api/_lib/pages.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Minimal runtime handler types for editor/typecheck support.

type ApiHandler<Env = Record<string, unknown>> = (context: {
request: Request;
env: Env;
params?: Record<string, string | undefined>;
}) => Response | Promise<Response>;
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export async function createReadModelsStore(
}

// Default to an in-memory empty store when DATABASE_URL is not configured.
// This keeps Pages deployments functional (ephemeral persistence) while still
// This keeps deployments functional (ephemeral persistence) while still
// allowing full persistence when DATABASE_URL is present.
if (!env.DATABASE_URL) {
const map = await getEmptyReadModelsMap();
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
35 changes: 35 additions & 0 deletions api/routes/admin/audit/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { assertAdmin } from "../../../_lib/clockStore.ts";
import { listAdminAudit } from "../../../_lib/adminAuditStore.ts";
import { errorResponse, jsonResponse } from "../../../_lib/http.ts";

const DEFAULT_LIMIT = 50;

export const onRequestGet: ApiHandler = async (context) => {
try {
assertAdmin(context);
} catch (error) {
const status = (error as Error & { status?: number }).status ?? 500;
return errorResponse(status, (error as Error).message);
}

const url = new URL(context.request.url);
const cursor = url.searchParams.get("cursor");
let beforeSeq: number | undefined;
if (cursor !== null) {
const parsed = Number.parseInt(cursor, 10);
if (!Number.isFinite(parsed) || parsed < 0) {
return errorResponse(400, "Invalid cursor");
}
beforeSeq = parsed;
}

const page = await listAdminAudit(context.env, {
beforeSeq,
limit: DEFAULT_LIMIT,
});
const response =
page.nextSeq !== undefined
? { items: page.items, nextCursor: String(page.nextSeq) }
: { items: page.items };
return jsonResponse(response);
};
199 changes: 199 additions & 0 deletions api/routes/admin/stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { eq, sql } from "drizzle-orm";

import {
adminState,
apiRateLimits,
chamberVotes,
cmAwards,
courtCases,
courtReports,
courtVerdicts,
eraRollups,
eraUserActivity,
events,
formationMilestoneEvents,
formationTeam,
poolVotes,
userActionLocks,
users,
} from "../../../db/schema.ts";
import { createDb } from "../../_lib/db.ts";
import { getCommandRateLimitConfig } from "../../_lib/apiRateLimitStore.ts";
import { assertAdmin, createClockStore } from "../../_lib/clockStore.ts";
import { getEraQuotaConfig } from "../../_lib/eraQuotas.ts";
import { listEraUserActivity } from "../../_lib/eraStore.ts";
import { errorResponse, jsonResponse } from "../../_lib/http.ts";

type Env = Record<string, string | undefined>;

function sum(
rows: Array<{
poolVotes: number;
chamberVotes: number;
courtActions: number;
formationActions: number;
}>,
) {
return rows.reduce(
(acc, r) => ({
poolVotes: acc.poolVotes + r.poolVotes,
chamberVotes: acc.chamberVotes + r.chamberVotes,
courtActions: acc.courtActions + r.courtActions,
formationActions: acc.formationActions + r.formationActions,
}),
{ poolVotes: 0, chamberVotes: 0, courtActions: 0, formationActions: 0 },
);
}

async function getWritesFrozen(env: Env): Promise<boolean> {
if (env.SIM_WRITE_FREEZE === "true") return true;
if (env.READ_MODELS_INLINE === "true") return false;
if (!env.DATABASE_URL) return false;
const db = createDb(env);
await db.insert(adminState).values({ id: 1 }).onConflictDoNothing();
const rows = await db
.select({ writesFrozen: adminState.writesFrozen })
.from(adminState)
.where(eq(adminState.id, 1))
.limit(1);
return rows[0]?.writesFrozen ?? false;
}

export const onRequestGet: ApiHandler = async (context) => {
try {
assertAdmin(context);
} catch (error) {
const status = (error as Error & { status?: number }).status ?? 500;
return errorResponse(status, (error as Error).message);
}

const clock = createClockStore(context.env);
const { currentEra } = await clock.get();
const rate = getCommandRateLimitConfig(context.env);
const quotas = getEraQuotaConfig(context.env);
const writesFrozen = await getWritesFrozen(context.env);

if (!context.env.DATABASE_URL) {
const rows = await listEraUserActivity(context.env, { era: currentEra });
const totals = sum(rows);
return jsonResponse({
currentEra,
writesFrozen,
config: {
rateLimitsPerMinute: rate,
eraQuotas: quotas,
},
currentEraActivity: {
rows: rows.length,
totals,
},
db: null,
});
}

const db = createDb(context.env);
const now = new Date();

const [
usersCount,
eventsCount,
adminAuditCount,
feedEventCount,
poolVotesCount,
chamberVotesCount,
cmAwardsCount,
formationTeamCount,
formationMilestoneEventsCount,
courtCasesCount,
courtReportsCount,
courtVerdictsCount,
rateLimitBucketsCount,
activeLocksCount,
currentEraActivityRowsCount,
currentEraActivityTotals,
rollupsCount,
] = await Promise.all([
db.select({ n: sql<number>`count(*)` }).from(users),
db.select({ n: sql<number>`count(*)` }).from(events),
db
.select({ n: sql<number>`count(*)` })
.from(events)
.where(sql`${events.type} = 'admin.action.v1'`),
db
.select({ n: sql<number>`count(*)` })
.from(events)
.where(sql`${events.type} = 'feed.item.v1'`),
db.select({ n: sql<number>`count(*)` }).from(poolVotes),
db.select({ n: sql<number>`count(*)` }).from(chamberVotes),
db.select({ n: sql<number>`count(*)` }).from(cmAwards),
db.select({ n: sql<number>`count(*)` }).from(formationTeam),
db.select({ n: sql<number>`count(*)` }).from(formationMilestoneEvents),
db.select({ n: sql<number>`count(*)` }).from(courtCases),
db.select({ n: sql<number>`count(*)` }).from(courtReports),
db.select({ n: sql<number>`count(*)` }).from(courtVerdicts),
db.select({ n: sql<number>`count(*)` }).from(apiRateLimits),
db
.select({ n: sql<number>`count(*)` })
.from(userActionLocks)
.where(sql`${userActionLocks.lockedUntil} > ${now}`),
db
.select({ n: sql<number>`count(*)` })
.from(eraUserActivity)
.where(sql`${eraUserActivity.era} = ${currentEra}`),
db
.select({
poolVotes: sql<number>`sum(${eraUserActivity.poolVotes})`,
chamberVotes: sql<number>`sum(${eraUserActivity.chamberVotes})`,
courtActions: sql<number>`sum(${eraUserActivity.courtActions})`,
formationActions: sql<number>`sum(${eraUserActivity.formationActions})`,
})
.from(eraUserActivity)
.where(sql`${eraUserActivity.era} = ${currentEra}`),
db.select({ n: sql<number>`count(*)` }).from(eraRollups),
]);

return jsonResponse({
currentEra,
writesFrozen,
config: {
rateLimitsPerMinute: rate,
eraQuotas: quotas,
},
db: {
users: Number(usersCount[0]?.n ?? 0),
events: {
total: Number(eventsCount[0]?.n ?? 0),
feedItems: Number(feedEventCount[0]?.n ?? 0),
adminAudit: Number(adminAuditCount[0]?.n ?? 0),
},
actions: {
poolVotes: Number(poolVotesCount[0]?.n ?? 0),
chamberVotes: Number(chamberVotesCount[0]?.n ?? 0),
cmAwards: Number(cmAwardsCount[0]?.n ?? 0),
formationTeam: Number(formationTeamCount[0]?.n ?? 0),
formationMilestoneEvents: Number(
formationMilestoneEventsCount[0]?.n ?? 0,
),
courtCases: Number(courtCasesCount[0]?.n ?? 0),
courtReports: Number(courtReportsCount[0]?.n ?? 0),
courtVerdicts: Number(courtVerdictsCount[0]?.n ?? 0),
},
hardening: {
rateLimitBuckets: Number(rateLimitBucketsCount[0]?.n ?? 0),
activeLocks: Number(activeLocksCount[0]?.n ?? 0),
},
eras: {
rollups: Number(rollupsCount[0]?.n ?? 0),
currentEraActivityRows: Number(currentEraActivityRowsCount[0]?.n ?? 0),
currentEraTotals: {
poolVotes: Number(currentEraActivityTotals[0]?.poolVotes ?? 0),
chamberVotes: Number(currentEraActivityTotals[0]?.chamberVotes ?? 0),
courtActions: Number(currentEraActivityTotals[0]?.courtActions ?? 0),
formationActions: Number(
currentEraActivityTotals[0]?.formationActions ?? 0,
),
},
},
},
});
};
54 changes: 54 additions & 0 deletions api/routes/admin/users/[address].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { assertAdmin } from "../../../_lib/clockStore.ts";
import { createActionLocksStore } from "../../../_lib/actionLocksStore.ts";
import { getEraQuotaConfig } from "../../../_lib/eraQuotas.ts";
import { getUserEraActivity } from "../../../_lib/eraStore.ts";
import { errorResponse, jsonResponse } from "../../../_lib/http.ts";

export const onRequestGet: ApiHandler<{ address: string }> = async (
context,
) => {
try {
assertAdmin(context);
} catch (error) {
const status = (error as Error & { status?: number }).status ?? 500;
return errorResponse(status, (error as Error).message);
}

const address = (context.params.address ?? "").trim();
if (!address) return errorResponse(400, "Missing address");

const activity = await getUserEraActivity(context.env, { address });
const quotas = getEraQuotaConfig(context.env);
const lock = await createActionLocksStore(context.env).getActiveLock(address);

const remaining = {
poolVotes:
quotas.maxPoolVotes === null
? null
: Math.max(0, quotas.maxPoolVotes - activity.counts.poolVotes),
chamberVotes:
quotas.maxChamberVotes === null
? null
: Math.max(0, quotas.maxChamberVotes - activity.counts.chamberVotes),
courtActions:
quotas.maxCourtActions === null
? null
: Math.max(0, quotas.maxCourtActions - activity.counts.courtActions),
formationActions:
quotas.maxFormationActions === null
? null
: Math.max(
0,
quotas.maxFormationActions - activity.counts.formationActions,
),
};

return jsonResponse({
address,
era: activity.era,
counts: activity.counts,
quotas,
remaining,
lock,
});
};
Loading