From a65e76f3d4d11aa9958efe2a17fc1d8e4c3ea47d Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:47:47 +0200 Subject: [PATCH] feat(transparency): public read API, audit trail page, and self-verifying decision documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S4+S5 of the Solon v1 plan: the integration surface consumers re-verify rather than trust. Public reads (auth-free — transparency is the product): - GET /api/orgs/[slug] — organization + public member roster (addresses, public keys, weights; there are no secrets to hide because Solon never holds any) - GET /api/orgs/[slug]/proposals — proposals with session state - GET /api/orgs/[slug]/policies/[key] — version history; v1 labeled bootstrap, later versions carry their approving session id - GET /api/orgs/[slug]/audit — append-only audit stream - GET /api/orgs/[slug]/treasury — live on-chain balances (moved from /api/bitcoin/wallet/[orgId]; slug-only, one canonical path) Keystone: GET /api/v1/decisions/[sessionId] — the self-verifying decision document: proposal with contentHash and the exact proposer message + signature, rules snapshotted at open, every vote's signed message + signature + public key, tally, outcome. The integration spec now does what OrangeCat will do: re-verifies every signature, re-hashes the content, and recomputes the tally from the votes — never trusting the server's arithmetic. Webhook rail: decision.finalized emitted on session close (HMAC X-Solon-Signature: sha256=, idempotent event_id). Doorbell, not courier — consumers fetch and re-verify the document. Env-gated (SOLON_WEBHOOK_URL/SECRET) and inert until configured; a DB-backed multi-endpoint rail is deferred until a second consumer exists. Site: /governance/audit renders the audit stream itself — no summaries, no derived metrics, nothing untraceable to a row. Nav + integration page updated to list exactly the live endpoints. Chore: .next excluded from tsconfig — stale generated route types broke typecheck after every route move/deletion (second occurrence; class ended). Co-Authored-By: Claude Fable 5 --- src/app/api/orgs/[slug]/audit/route.ts | 24 ++++ .../api/orgs/[slug]/policies/[key]/route.ts | 34 ++++++ src/app/api/orgs/[slug]/proposals/route.ts | 36 ++++++ src/app/api/orgs/[slug]/route.ts | 42 +++++++ .../[orgId] => orgs/[slug]/treasury}/route.ts | 14 +-- .../api/sessions/[sessionId]/close/route.ts | 21 ++++ src/app/api/v1/decisions/[sessionId]/route.ts | 16 +++ src/app/governance/audit/page.tsx | 85 ++++++++++++++ src/app/integration/page.tsx | 44 +++++++- .../__tests__/vote-spine.integration.test.ts | 34 +++++- src/lib/domain/decision.ts | 106 ++++++++++++++++++ src/lib/site-config.ts | 5 +- src/lib/webhooks.ts | 58 ++++++++++ tsconfig.json | 3 +- 14 files changed, 506 insertions(+), 16 deletions(-) create mode 100644 src/app/api/orgs/[slug]/audit/route.ts create mode 100644 src/app/api/orgs/[slug]/policies/[key]/route.ts create mode 100644 src/app/api/orgs/[slug]/proposals/route.ts create mode 100644 src/app/api/orgs/[slug]/route.ts rename src/app/api/{bitcoin/wallet/[orgId] => orgs/[slug]/treasury}/route.ts (64%) create mode 100644 src/app/api/v1/decisions/[sessionId]/route.ts create mode 100644 src/app/governance/audit/page.tsx create mode 100644 src/lib/domain/decision.ts create mode 100644 src/lib/webhooks.ts diff --git a/src/app/api/orgs/[slug]/audit/route.ts b/src/app/api/orgs/[slug]/audit/route.ts new file mode 100644 index 0000000..c42d2a1 --- /dev/null +++ b/src/app/api/orgs/[slug]/audit/route.ts @@ -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, + }); +} diff --git a/src/app/api/orgs/[slug]/policies/[key]/route.ts b/src/app/api/orgs/[slug]/policies/[key]/route.ts new file mode 100644 index 0000000..fb61c24 --- /dev/null +++ b/src/app/api/orgs/[slug]/policies/[key]/route.ts @@ -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, + })), + }); +} diff --git a/src/app/api/orgs/[slug]/proposals/route.ts b/src/app/api/orgs/[slug]/proposals/route.ts new file mode 100644 index 0000000..e2c11ed --- /dev/null +++ b/src/app/api/orgs/[slug]/proposals/route.ts @@ -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, + })), + }); +} diff --git a/src/app/api/orgs/[slug]/route.ts b/src/app/api/orgs/[slug]/route.ts new file mode 100644 index 0000000..93b0aa8 --- /dev/null +++ b/src/app/api/orgs/[slug]/route.ts @@ -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) })), + }); +} diff --git a/src/app/api/bitcoin/wallet/[orgId]/route.ts b/src/app/api/orgs/[slug]/treasury/route.ts similarity index 64% rename from src/app/api/bitcoin/wallet/[orgId]/route.ts rename to src/app/api/orgs/[slug]/treasury/route.ts index a843196..7ab88ed 100644 --- a/src/app/api/bitcoin/wallet/[orgId]/route.ts +++ b/src/app/api/orgs/[slug]/treasury/route.ts @@ -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); diff --git a/src/app/api/sessions/[sessionId]/close/route.ts b/src/app/api/sessions/[sessionId]/close/route.ts index f013ac6..4cc6db2 100644 --- a/src/app/api/sessions/[sessionId]/close/route.ts +++ b/src/app/api/sessions/[sessionId]/close/route.ts @@ -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"; diff --git a/src/app/api/v1/decisions/[sessionId]/route.ts b/src/app/api/v1/decisions/[sessionId]/route.ts new file mode 100644 index 0000000..25730a4 --- /dev/null +++ b/src/app/api/v1/decisions/[sessionId]/route.ts @@ -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); +} diff --git a/src/app/governance/audit/page.tsx b/src/app/governance/audit/page.tsx new file mode 100644 index 0000000..525a848 --- /dev/null +++ b/src/app/governance/audit/page.tsx @@ -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 = { + 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 ( + +
+ {dbError && ( +

+ The audit register is currently unreachable. No events can be shown. +

+ )} + {!dbError && !org && ( +

+ No organization is registered yet, so there is no audit trail to show. +

+ )} + {org && ( + <> +

+ {events.length} most recent events for {org.name}. + Audit events are append-only: no code path updates or deletes them. +

+
    + {events.map((e) => ( +
  1. +
    + {EVENT_LABEL[e.eventType]} + +
    +
    + {e.subjectType}:{e.subjectId} +
    +
    +                    {JSON.stringify(e.payload, null, 2)}
    +                  
    +
  2. + ))} +
+ + )} +
+
+ ); +} diff --git a/src/app/integration/page.tsx b/src/app/integration/page.tsx index 8b87cc8..d997a92 100644 --- a/src/app/integration/page.tsx +++ b/src/app/integration/page.tsx @@ -74,9 +74,41 @@ export default function IntegrationPage() {
  • - GET /api/bitcoin/wallet/[orgId] + GET /api/orgs/[slug] - organization wallet balance + organization + public member roster +
  • +
  • + + GET /api/orgs/[slug]/proposals + + all proposals with session state +
  • +
  • + + GET /api/orgs/[slug]/policies/[key] + + policy version history +
  • +
  • + + GET /api/orgs/[slug]/audit + + append-only audit stream +
  • +
  • + + GET /api/orgs/[slug]/treasury + + live on-chain treasury balances +
  • +
  • + + GET /api/v1/decisions/[sessionId] + + + self-verifying decision document — re-verify it, don't trust it +
  • @@ -86,9 +118,11 @@ export default function IntegrationPage() {
  • - 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 decision.finalized{" "} + webhook (HMAC-signed); consumers are expected to fetch the decision + document and re-verify every signature locally rather than trust the + notification.

    diff --git a/src/lib/domain/__tests__/vote-spine.integration.test.ts b/src/lib/domain/__tests__/vote-spine.integration.test.ts index b323d4b..239633b 100644 --- a/src/lib/domain/__tests__/vote-spine.integration.test.ts +++ b/src/lib/domain/__tests__/vote-spine.integration.test.ts @@ -16,8 +16,15 @@ import { SessionOutcome, } from "@prisma/client"; import { prisma } from "@/lib/db"; -import { generateKeyPair, proposalMessage, signMessage, voteMessage } from "@/lib/bitcoin/message"; +import { + generateKeyPair, + proposalMessage, + signMessage, + verifyMessage, + voteMessage, +} from "@/lib/bitcoin/message"; import { contentHashOf, sha256Hex } from "@/lib/domain/canonical"; +import { decisionDocument } from "@/lib/domain/decision"; import { createProposal } from "@/lib/domain/proposals"; import { closeSession, openSession, submitVote } from "@/lib/domain/voting"; @@ -142,6 +149,31 @@ describe.runIf(RUN)("vote spine (database integration)", () => { }); expect(v1.status).toBe(PolicyStatus.SUPERSEDED); + // --- The decision document self-verifies, the way a consumer would --- + const doc = await decisionDocument(session.id); + if (!doc.found || !doc.finalized) throw new Error("decision document missing after close"); + const d = doc.document; + expect(d.outcome).toBe(SessionOutcome.APPROVED); + expect(d.proposal.contentHash).toBe(contentHash); + expect(contentHashOf(d.proposal.proposedContent)).toBe(d.proposal.contentHash); + expect( + verifyMessage(d.proposal.proposerMessage, d.proposal.proposer.bitcoinAddress, d.proposal.proposerSignature) + .valid, + ).toBe(true); + expect(d.votes).toHaveLength(3); + for (const v of d.votes) { + expect(verifyMessage(v.signedMessage, v.member.bitcoinAddress, v.signature).valid).toBe(true); + // The signed message must bind THIS session and THIS voter. + expect(v.signedMessage).toContain(`session:${session.id}`); + expect(v.signedMessage).toContain(`voter:${v.member.bitcoinAddress}`); + } + // Recompute the tally from the votes — never trust the server's arithmetic. + const recomputed = d.votes.reduce( + (t, v) => ({ ...t, [v.choice.toLowerCase()]: t[v.choice.toLowerCase() as "yes" | "no" | "abstain"] + v.weight }), + { yes: 0, no: 0, abstain: 0 }, + ); + expect(recomputed).toEqual(d.tally); + // --- The audit trail recorded every step --- const events = await prisma.auditEvent.findMany({ where: { organizationId: org.id }, diff --git a/src/lib/domain/decision.ts b/src/lib/domain/decision.ts new file mode 100644 index 0000000..46afad1 --- /dev/null +++ b/src/lib/domain/decision.ts @@ -0,0 +1,106 @@ +import { SessionStatus } from "@prisma/client"; +import { prisma } from "@/lib/db"; +import { proposalMessage } from "@/lib/bitcoin/message"; +import { tally } from "@/lib/domain/tally"; + +/** + * The self-verifying decision document — Solon's keystone artifact. It carries + * everything a consumer needs to re-verify the decision locally: the proposal + * with its content hash, the rules snapshotted at open, and every vote's exact + * signed message + signature + public key. Consumers (OrangeCat) re-verify + * against their own pinned trusted keys and recompute the tally; they never + * trust this server's arithmetic. + */ +export async function decisionDocument(sessionId: string) { + const session = await prisma.votingSession.findUnique({ + where: { id: sessionId }, + include: { + proposal: { + include: { + organization: { select: { id: true, slug: true, name: true } }, + proposer: { + select: { + id: true, + displayName: true, + memberType: true, + bitcoinAddress: true, + publicKeyHex: true, + }, + }, + }, + }, + votes: { + include: { + member: { + select: { + id: true, + displayName: true, + memberType: true, + bitcoinAddress: true, + publicKeyHex: true, + }, + }, + }, + orderBy: { createdAt: "asc" }, + }, + }, + }); + if (!session) return { found: false as const, reason: "voting session not found" }; + if (session.status !== SessionStatus.CLOSED) { + return { found: true as const, finalized: false as const, reason: "session is not closed yet — no decision exists" }; + } + + const p = session.proposal; + return { + found: true as const, + finalized: true as const, + document: { + decision_id: session.id, + organization: p.organization, + proposal: { + id: p.id, + category: p.category, + title: p.title, + body: p.body, + policyKey: p.policyKey, + proposedContent: p.proposedContent, + target: p.target, + contentHash: p.contentHash, + proposer: p.proposer, + // The exact message the proposer signed, reconstructable from fields above. + proposerMessage: proposalMessage({ + orgSlug: p.organization.slug, + category: p.category, + title: p.title, + proposerAddress: p.proposer.bitcoinAddress, + contentHash: p.contentHash, + }), + proposerSignature: p.proposerSignature, + }, + rules: { + electorate: session.electorate, + threshold: session.threshold, + quorumPercent: session.quorumPercent, + eligibleCount: session.eligibleCount, + eligibleWeight: Number(session.eligibleWeight), + opensAt: session.opensAt, + closesAt: session.closesAt, + }, + votes: session.votes.map((v) => ({ + member: v.member, + choice: v.choice, + weight: Number(v.weight), + signedMessage: v.signedMessage, + signature: v.signature, + castAt: v.createdAt, + })), + tally: tally(session.votes.map((v) => ({ choice: v.choice, weight: Number(v.weight) }))), + outcome: session.outcome, + closedAt: session.closedAt, + }, + }; +} + +export type DecisionDocument = NonNullable< + Extract>, { finalized: true }>["document"] +>; diff --git a/src/lib/site-config.ts b/src/lib/site-config.ts index 665ed48..568f32a 100644 --- a/src/lib/site-config.ts +++ b/src/lib/site-config.ts @@ -22,7 +22,10 @@ export const NAV_ITEMS: NavSection[] = [ }, { title: 'Governance', - children: [{ title: 'Voting System', href: '/governance/voting' }], + children: [ + { title: 'Voting System', href: '/governance/voting' }, + { title: 'Audit Trail', href: '/governance/audit' }, + ], }, { title: 'Treasury', diff --git a/src/lib/webhooks.ts b/src/lib/webhooks.ts new file mode 100644 index 0000000..667a167 --- /dev/null +++ b/src/lib/webhooks.ts @@ -0,0 +1,58 @@ +import { createHmac } from "node:crypto"; + +/** + * Outbound webhook: `decision.finalized`, fired when a voting session closes. + * + * The webhook is a doorbell, not a courier: the payload names the decision, + * and the consumer fetches the decision document itself and re-verifies every + * signature before acting. Missed deliveries are healed by the consumer's own + * reconciliation poll, so a best-effort retry here is enough. + * + * Env-gated and inert until configured (fleet convention): set + * SOLON_WEBHOOK_URL and SOLON_WEBHOOK_SECRET to enable. One consumer (OC) is + * the v1 reality — a DB-backed multi-endpoint rail is deferred until a second + * consumer exists. + */ +export interface DecisionFinalizedEvent { + event: "decision.finalized"; + event_id: string; + decision_id: string; + org_id: string; + org_slug: string; + target: string | null; + content_hash: string | null; + outcome: string; +} + +const ATTEMPTS = 3; +const BACKOFF_MS = [0, 2000, 10000]; + +export async function emitDecisionFinalized(payload: Omit): Promise { + const url = process.env.SOLON_WEBHOOK_URL; + const secret = process.env.SOLON_WEBHOOK_SECRET; + if (!url || !secret) return; // not configured — silently inert + + const event: DecisionFinalizedEvent = { + event: "decision.finalized", + // The decision id IS the idempotency key: a session finalizes exactly once. + event_id: `decision.finalized:${payload.decision_id}`, + ...payload, + }; + const body = JSON.stringify(event); + const signature = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; + + for (let attempt = 0; attempt < ATTEMPTS; attempt++) { + if (BACKOFF_MS[attempt]) await new Promise((r) => setTimeout(r, BACKOFF_MS[attempt])); + try { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Solon-Signature": signature }, + body, + }); + if (res.ok) return; + console.error(`webhook decision.finalized ${payload.decision_id}: HTTP ${res.status} (attempt ${attempt + 1}/${ATTEMPTS})`); + } catch (e) { + console.error(`webhook decision.finalized ${payload.decision_id}: ${e instanceof Error ? e.message : e} (attempt ${attempt + 1}/${ATTEMPTS})`); + } + } +} diff --git a/tsconfig.json b/tsconfig.json index 3ddb88f..4febdae 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -42,6 +42,7 @@ ".next/types/**/*.ts" ], "exclude": [ - "node_modules" + "node_modules", + ".next" ] }