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
24 changes: 24 additions & 0 deletions src/app/api/orgs/[slug]/audit/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";

export const dynamic = "force-dynamic";

/** Public read: the append-only audit stream, newest first. */
export async function GET(req: Request, { params }: { params: { slug: string } }) {
const org = await prisma.organization.findUnique({ where: { slug: params.slug } });
if (!org) return NextResponse.json({ error: "Organization not found" }, { status: 404 });

const limitParam = Number(new URL(req.url).searchParams.get("limit"));
const take = Number.isInteger(limitParam) && limitParam > 0 ? Math.min(limitParam, 500) : 200;

const events = await prisma.auditEvent.findMany({
where: { organizationId: org.id },
orderBy: { createdAt: "desc" },
take,
});

return NextResponse.json({
organization: { id: org.id, slug: org.slug },
events,
});
}
34 changes: 34 additions & 0 deletions src/app/api/orgs/[slug]/policies/[key]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";

export const dynamic = "force-dynamic";

/**
* Public read: the full version history of a policy. Version 1 is the seeded
* bootstrap (approvedBySessionId null, labeled as such); every later version
* references the APPROVED voting session that legitimated it.
*/
export async function GET(_: Request, { params }: { params: { slug: string; key: string } }) {
const org = await prisma.organization.findUnique({ where: { slug: params.slug } });
if (!org) return NextResponse.json({ error: "Organization not found" }, { status: 404 });

const versions = await prisma.policy.findMany({
where: { organizationId: org.id, key: params.key },
orderBy: { version: "desc" },
});
if (versions.length === 0) return NextResponse.json({ error: "Policy not found" }, { status: 404 });

return NextResponse.json({
organization: { id: org.id, slug: org.slug },
key: params.key,
active: versions.find((v) => v.status === "ACTIVE")?.version ?? null,
versions: versions.map((v) => ({
version: v.version,
content: v.content,
status: v.status,
approvedBySessionId: v.approvedBySessionId,
bootstrap: v.approvedBySessionId === null,
activatedAt: v.activatedAt,
})),
});
}
36 changes: 36 additions & 0 deletions src/app/api/orgs/[slug]/proposals/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";

export const dynamic = "force-dynamic";

/** Public read: all proposals of an organization, newest first. */
export async function GET(_: Request, { params }: { params: { slug: string } }) {
const org = await prisma.organization.findUnique({ where: { slug: params.slug } });
if (!org) return NextResponse.json({ error: "Organization not found" }, { status: 404 });

const proposals = await prisma.proposal.findMany({
where: { organizationId: org.id },
orderBy: { createdAt: "desc" },
take: 200,
include: {
proposer: { select: { displayName: true, memberType: true, bitcoinAddress: true } },
session: { select: { id: true, status: true, outcome: true, closesAt: true } },
},
});

return NextResponse.json({
organization: { id: org.id, slug: org.slug },
proposals: proposals.map((p) => ({
id: p.id,
category: p.category,
title: p.title,
status: p.status,
policyKey: p.policyKey,
target: p.target,
contentHash: p.contentHash,
proposer: p.proposer,
session: p.session,
createdAt: p.createdAt,
})),
});
}
42 changes: 42 additions & 0 deletions src/app/api/orgs/[slug]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";

export const dynamic = "force-dynamic";

/**
* Public read: an organization and its member roster. Transparency is the
* product — who can vote, with what weight, human or agent, is public record.
* Private keys never exist here; addresses and public keys are the whole story.
*/
export async function GET(_: Request, { params }: { params: { slug: string } }) {
const org = await prisma.organization.findUnique({
where: { slug: params.slug },
include: {
members: {
select: {
id: true,
displayName: true,
memberType: true,
keyCustody: true,
bitcoinAddress: true,
publicKeyHex: true,
votingWeight: true,
status: true,
system: true,
joinedAt: true,
},
orderBy: { joinedAt: "asc" },
},
},
});
if (!org) return NextResponse.json({ error: "Organization not found" }, { status: 404 });

return NextResponse.json({
id: org.id,
slug: org.slug,
name: org.name,
description: org.description,
createdAt: org.createdAt,
members: org.members.map((m) => ({ ...m, votingWeight: Number(m.votingWeight) })),
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,13 @@ import { treasuryReport } from "@/lib/domain/treasury";
export const dynamic = "force-dynamic";

/**
* Watch-only treasury balances for an organization (by id or slug). Every
* number is read live from the chain; a failed lookup reports null, never a
* substitute.
* Watch-only treasury balances for an organization. Every number is read live
* from the chain via mempool.space; a failed lookup reports null, never a
* substitute, and every address links to a public explorer so the claim is
* independently checkable.
*/
export async function GET(_: Request, { params }: { params: { orgId: string } }) {
const { orgId } = params;
const org = await prisma.organization.findFirst({
where: { OR: [{ id: orgId }, { slug: orgId }] },
});
export async function GET(_: Request, { params }: { params: { slug: string } }) {
const org = await prisma.organization.findUnique({ where: { slug: params.slug } });
if (!org) return NextResponse.json({ error: "Organization not found" }, { status: 404 });

const report = await treasuryReport(org.id);
Expand Down
21 changes: 21 additions & 0 deletions src/app/api/sessions/[sessionId]/close/route.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,36 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { closeSession } from "@/lib/domain/voting";
import { emitDecisionFinalized } from "@/lib/webhooks";

/**
* Close a session and decide its outcome from the rules snapshotted at open.
* Permissionless but time-gated: the domain layer refuses to close while the
* voting window is open unless every eligible member has already voted, so
* nobody can slam the door on a tally they like.
*
* On close, `decision.finalized` is emitted to the configured webhook (a
* doorbell — consumers fetch and re-verify the decision document themselves).
*/
export async function POST(_: Request, { params }: { params: { sessionId: string } }) {
try {
const result = await closeSession(params.sessionId);

const proposal = await prisma.proposal.findFirst({
where: { session: { id: params.sessionId } },
include: { organization: { select: { id: true, slug: true } } },
});
if (proposal) {
await emitDecisionFinalized({
decision_id: params.sessionId,
org_id: proposal.organization.id,
org_slug: proposal.organization.slug,
target: proposal.target,
content_hash: proposal.contentHash,
outcome: result.outcome,
});
}

return NextResponse.json({ closed: true, outcome: result.outcome, tally: result.tally });
} catch (e) {
const message = e instanceof Error ? e.message : "failed to close session";
Expand Down
16 changes: 16 additions & 0 deletions src/app/api/v1/decisions/[sessionId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { decisionDocument } from "@/lib/domain/decision";

export const dynamic = "force-dynamic";

/**
* The keystone read: a finalized decision as a self-verifying document.
* Consumers re-verify every signature and recompute the tally locally —
* this endpoint is evidence, not authority.
*/
export async function GET(_: Request, { params }: { params: { sessionId: string } }) {
const result = await decisionDocument(params.sessionId);
if (!result.found) return NextResponse.json({ error: result.reason }, { status: 404 });
if (!result.finalized) return NextResponse.json({ error: result.reason }, { status: 409 });
return NextResponse.json(result.document);
}
85 changes: 85 additions & 0 deletions src/app/governance/audit/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import PageLayout from "@/components/ui/page-layout";
import { prisma } from "@/lib/db";
import type { AuditEvent, AuditEventType } from "@prisma/client";

export const dynamic = "force-dynamic";

const EVENT_LABEL: Record<AuditEventType, string> = {
ORG_CREATED: "Organization created",
MEMBER_ADDED: "Member added",
MEMBER_STATUS_CHANGED: "Member status changed",
PROPOSAL_CREATED: "Proposal filed",
SESSION_OPENED: "Voting session opened",
VOTE_CAST: "Vote cast",
SESSION_CLOSED: "Voting session closed",
POLICY_ACTIVATED: "Policy version activated",
};

/**
* The public audit trail: every governance event, append-only, rendered
* straight from the database. This page shows the record itself — no
* summaries, no derived metrics, nothing that can't be traced to a row.
*/
export default async function AuditPage() {
let org = null;
let events: AuditEvent[] = [];
let dbError = false;
try {
org = await prisma.organization.findFirst({ orderBy: { createdAt: "asc" } });
if (org) {
events = await prisma.auditEvent.findMany({
where: { organizationId: org.id },
orderBy: { createdAt: "desc" },
take: 200,
});
}
} catch {
dbError = true;
}

return (
<PageLayout
title="Audit Trail"
description="Every governance event, append-only — the record itself, not a summary of it"
>
<div className="max-w-4xl mx-auto space-y-6">
{dbError && (
<p className="text-center text-gray-600">
The audit register is currently unreachable. No events can be shown.
</p>
)}
{!dbError && !org && (
<p className="text-center text-gray-600">
No organization is registered yet, so there is no audit trail to show.
</p>
)}
{org && (
<>
<p className="text-sm text-gray-600 text-center">
{events.length} most recent events for <span className="font-semibold">{org.name}</span>.
Audit events are append-only: no code path updates or deletes them.
</p>
<ol className="space-y-3">
{events.map((e) => (
<li key={e.id} className="bg-white rounded-xl border border-gray-200 shadow-sm p-4">
<div className="flex items-baseline justify-between gap-4">
<span className="font-semibold text-[var(--navy)]">{EVENT_LABEL[e.eventType]}</span>
<time className="text-xs text-gray-500 whitespace-nowrap" dateTime={e.createdAt.toISOString()}>
{e.createdAt.toISOString().replace("T", " ").slice(0, 19)} UTC
</time>
</div>
<div className="mt-1 text-xs text-gray-500 font-mono">
{e.subjectType}:{e.subjectId}
</div>
<pre className="mt-2 p-2 rounded-md bg-gray-50 border border-gray-100 text-xs font-mono whitespace-pre-wrap break-all text-gray-700">
{JSON.stringify(e.payload, null, 2)}
</pre>
</li>
))}
</ol>
</>
)}
</div>
</PageLayout>
);
}
44 changes: 39 additions & 5 deletions src/app/integration/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,41 @@ export default function IntegrationPage() {
</li>
<li>
<code className="bg-gray-100 px-2 py-1 rounded text-xs text-[var(--navy)] font-mono">
GET /api/bitcoin/wallet/[orgId]
GET /api/orgs/[slug]
</code>
<span className="text-sm text-gray-600 ml-2">organization wallet balance</span>
<span className="text-sm text-gray-600 ml-2">organization + public member roster</span>
</li>
<li>
<code className="bg-gray-100 px-2 py-1 rounded text-xs text-[var(--navy)] font-mono">
GET /api/orgs/[slug]/proposals
</code>
<span className="text-sm text-gray-600 ml-2">all proposals with session state</span>
</li>
<li>
<code className="bg-gray-100 px-2 py-1 rounded text-xs text-[var(--navy)] font-mono">
GET /api/orgs/[slug]/policies/[key]
</code>
<span className="text-sm text-gray-600 ml-2">policy version history</span>
</li>
<li>
<code className="bg-gray-100 px-2 py-1 rounded text-xs text-[var(--navy)] font-mono">
GET /api/orgs/[slug]/audit
</code>
<span className="text-sm text-gray-600 ml-2">append-only audit stream</span>
</li>
<li>
<code className="bg-gray-100 px-2 py-1 rounded text-xs text-[var(--navy)] font-mono">
GET /api/orgs/[slug]/treasury
</code>
<span className="text-sm text-gray-600 ml-2">live on-chain treasury balances</span>
</li>
<li>
<code className="bg-gray-100 px-2 py-1 rounded text-xs text-[var(--navy)] font-mono">
GET /api/v1/decisions/[sessionId]
</code>
<span className="text-sm text-gray-600 ml-2">
self-verifying decision document — re-verify it, don&apos;t trust it
</span>
</li>
<li>
<code className="bg-gray-100 px-2 py-1 rounded text-xs text-[var(--navy)] font-mono">
Expand All @@ -86,9 +118,11 @@ export default function IntegrationPage() {
</li>
</ul>
<p className="text-gray-600 text-sm mt-4">
A broader public read API (organizations, proposals, decisions, treasury,
audit log) is being built next — endpoints appear here when they are live,
not before.
All reads are public and auth-free — transparency is the product. On
finalization Solon emits a <code className="font-mono text-xs">decision.finalized</code>{" "}
webhook (HMAC-signed); consumers are expected to fetch the decision
document and re-verify every signature locally rather than trust the
notification.
</p>
</div>
</div>
Expand Down
Loading