From f47092090adadfb9d63dfacf9d11c4b2f4e311db Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:38:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(governance):=20vote=20spine=20end-to-end?= =?UTF-8?q?=20=E2=80=94=20propose,=20open,=20vote,=20close=20over=20HTTP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S3 of the Solon v1 plan: the domain layer gets its public write surface, a reference agent signer, and a database-backed acceptance spec in CI. Routes (thin Zod-validated shells over lib/domain): - POST /api/proposals — file a signed proposal; the Bitcoin signature over proposalMessage() is the authorization, and AGENT proposers must also present their transport API key (Authorization: Bearer sk_solon_…) - POST /api/proposals/[id]/open — open the voting session (permissionless by design: the proposal is already signed and public, opening only starts the clock, and it can happen exactly once) - GET /api/sessions/[id] — session, snapshotted rules, live tally - POST /api/sessions/[id]/votes — cast a signed vote (replaces /api/voting/[id]/cryptographic-vote; UI + integration page updated) - POST /api/sessions/[id]/close — close and decide the outcome New domain code: - lib/domain/proposals.ts createProposal(): content binding via sha256 of canonical JSON (lib/domain/canonical.ts — the cross-repo contract OrangeCat will re-hash against, pinned by golden-hash tests) - closeRefusal() guard: a session cannot be closed while the voting window is open unless every eligible member has voted — nobody slams the door on a tally they like Scripts: - scripts/agent-vote.ts — reference agent signer (privkey from the agent's own env, signs and POSTs; the key never leaves the agent's box) - scripts/add-member.ts — documented operator bootstrap for the genesis roster, with MEMBER_ADDED audit events and one-time API-key minting CI: new `integration` job — postgres service container, `prisma migrate deploy` on a fresh database (proves baseline + seed replay), then the vote-spine integration spec: propose (agent transport auth enforced) → open (rules snapshotted) → three signed votes → early-close refused → full participation closes → APPROVED → policy v2 activated referencing the session, v1 superseded, every step in the audit trail; plus the humans-only electorate gate. Verified locally against a dockerized postgres 16. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 42 ++ package-lock.json | 519 ++++++++++++++++++ package.json | 1 + scripts/add-member.ts | 113 ++++ scripts/agent-vote.ts | 63 +++ .../api/proposals/[proposalId]/open/route.ts | 18 + src/app/api/proposals/route.ts | 42 ++ .../api/sessions/[sessionId]/close/route.ts | 18 + src/app/api/sessions/[sessionId]/route.ts | 31 ++ .../api/sessions/[sessionId]/votes/route.ts | 34 ++ .../[sessionId]/cryptographic-vote/route.ts | 39 -- src/app/integration/page.tsx | 26 +- src/components/dashboard/voting-interface.tsx | 2 +- src/lib/domain/__tests__/canonical.test.ts | 27 + src/lib/domain/__tests__/tally.test.ts | 17 +- .../__tests__/vote-spine.integration.test.ts | 217 ++++++++ src/lib/domain/canonical.ts | 28 + src/lib/domain/proposals.ts | 131 +++++ src/lib/domain/tally.ts | 17 + src/lib/domain/voting.ts | 11 +- 20 files changed, 1350 insertions(+), 46 deletions(-) create mode 100644 scripts/add-member.ts create mode 100644 scripts/agent-vote.ts create mode 100644 src/app/api/proposals/[proposalId]/open/route.ts create mode 100644 src/app/api/proposals/route.ts create mode 100644 src/app/api/sessions/[sessionId]/close/route.ts create mode 100644 src/app/api/sessions/[sessionId]/route.ts create mode 100644 src/app/api/sessions/[sessionId]/votes/route.ts delete mode 100644 src/app/api/voting/[sessionId]/cryptographic-vote/route.ts create mode 100644 src/lib/domain/__tests__/canonical.test.ts create mode 100644 src/lib/domain/__tests__/vote-spine.integration.test.ts create mode 100644 src/lib/domain/canonical.ts create mode 100644 src/lib/domain/proposals.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e23795..cea0eaa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,3 +45,45 @@ jobs: # Build gate — hermetic (Prisma calls fall back without a live DB, see header). - name: Build run: npm run build + + # The vote spine against a real database: migration replay on a fresh + # postgres (proves the baseline + seed actually apply), then the + # propose → open → vote → close → policy-activation integration spec. + integration: + runs-on: ubuntu-latest + timeout-minutes: 15 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: ci + POSTGRES_PASSWORD: ci + POSTGRES_DB: ci + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U ci" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + DATABASE_URL: postgres://ci:ci@localhost:5432/ci + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: 20 + cache: npm + + - name: Install + run: npm ci + + - name: Prisma generate + run: npx prisma generate + + - name: Migration replay (baseline + seed on a fresh database) + run: npx prisma migrate deploy + + - name: Vote spine integration spec + run: INTEGRATION=1 npx vitest run src/lib/domain/__tests__/vote-spine.integration.test.ts diff --git a/package-lock.json b/package-lock.json index d5b2f19..fb5aa90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "prisma": "5.17.0", "puppeteer": "22.15.0", "ts-node": "10.9.2", + "tsx": "^4.23.11", "typescript": "5.5.3", "vitest": "^4.1.10" } @@ -132,6 +133,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -3260,6 +3703,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -7813,6 +8298,40 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.11", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.11.tgz", + "integrity": "sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index de52032..74fcd0b 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "prisma": "5.17.0", "puppeteer": "22.15.0", "ts-node": "10.9.2", + "tsx": "^4.23.11", "typescript": "5.5.3", "vitest": "^4.1.10" } diff --git a/scripts/add-member.ts b/scripts/add-member.ts new file mode 100644 index 0000000..74c7c04 --- /dev/null +++ b/scripts/add-member.ts @@ -0,0 +1,113 @@ +/** + * Operator bootstrap: register a member (human or agent) in an organization. + * + * Membership changes are a HUMANS_ONLY decision category, but the genesis + * roster has to come from somewhere — this script is that documented, + * operator-run bootstrap. It runs on the box with DATABASE_URL set and writes + * a MEMBER_ADDED audit event like every other roster change. + * + * Usage: + * npx tsx scripts/add-member.ts --org orangecat --name "George" \ + * --type HUMAN --address [--pubkey ] \ + * [--system orangecat:cat] [--weight 1] [--mint-key] + * + * --mint-key (agents only) generates a transport API key, stores its sha256, + * and prints the plaintext ONCE — it is never stored or shown again. + */ +import { parseArgs } from "node:util"; +import { randomBytes } from "node:crypto"; +import { prisma } from "../src/lib/db"; +import { sha256Hex } from "../src/lib/domain/canonical"; + +const { values } = parseArgs({ + options: { + org: { type: "string" }, + name: { type: "string" }, + type: { type: "string" }, + address: { type: "string" }, + pubkey: { type: "string" }, + system: { type: "string" }, + weight: { type: "string", default: "1" }, + "mint-key": { type: "boolean", default: false }, + }, +}); + +async function main() { + const { org, name, type, address } = values; + if (!org || !name || !type || !address) { + console.error("required: --org --name --type HUMAN|AGENT --address"); + process.exit(1); + } + if (type !== "HUMAN" && type !== "AGENT") { + console.error(`--type must be HUMAN or AGENT, got ${type}`); + process.exit(1); + } + if (type === "AGENT" && !values.system) { + console.error("agents need --system (e.g. orangecat:cat) — which system attests this member's votes"); + process.exit(1); + } + + const organization = await prisma.organization.findUnique({ where: { slug: org } }); + if (!organization) { + console.error(`organization "${org}" not found`); + process.exit(1); + } + + const existing = await prisma.member.findUnique({ + where: { organizationId_bitcoinAddress: { organizationId: organization.id, bitcoinAddress: address } }, + }); + if (existing) { + console.log(`member already registered: ${existing.id} (${existing.displayName})`); + process.exit(0); + } + + const member = await prisma.$transaction(async (tx) => { + const m = await tx.member.create({ + data: { + organizationId: organization.id, + displayName: name, + memberType: type, + // Humans hold their own keys; an agent's key lives in its system's env. + keyCustody: type === "HUMAN" ? "SELF" : "SERVICE", + bitcoinAddress: address, + publicKeyHex: values.pubkey ?? null, + votingWeight: values.weight, + system: values.system ?? null, + }, + }); + await tx.auditEvent.create({ + data: { + organizationId: organization.id, + eventType: "MEMBER_ADDED", + subjectType: "member", + subjectId: m.id, + payload: { + displayName: name, + memberType: type, + bitcoinAddress: address, + ...(values.system ? { system: values.system } : {}), + note: "operator bootstrap — roster changes after genesis go through MEMBERSHIP votes", + }, + }, + }); + return m; + }); + console.log(`member created: ${member.id} (${member.displayName}, ${member.memberType})`); + + if (values["mint-key"]) { + if (type !== "AGENT") { + console.error("--mint-key is for AGENT members only"); + process.exit(1); + } + const plaintext = `sk_solon_${randomBytes(24).toString("hex")}`; + await prisma.agentApiKey.create({ data: { memberId: member.id, keyHash: sha256Hex(plaintext) } }); + console.log(`API key (shown once, store it in the agent's env now):\n${plaintext}`); + } +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/scripts/agent-vote.ts b/scripts/agent-vote.ts new file mode 100644 index 0000000..29ad7e4 --- /dev/null +++ b/scripts/agent-vote.ts @@ -0,0 +1,63 @@ +/** + * Reference agent signer: cast a Bitcoin-signed vote from an agent's own box. + * + * The private key never leaves the agent's environment — Solon only ever sees + * the signature. This is the script OC (the Cat) and FC (Loki) run; a vote + * cast with it is that system's attested judgment. + * + * Usage: + * SOLON_AGENT_PRIVKEY= npx tsx scripts/agent-vote.ts \ + * --base-url https://solon.orangecat.ch --session --choice yes + * + * --key-env reads the key from a different env var (e.g. CAT_SOLON_PRIVKEY). + */ +import { parseArgs } from "node:util"; +import * as secp from "@noble/secp256k1"; +import { deriveAddresses, signMessage, voteMessage } from "../src/lib/bitcoin/message"; + +const { values } = parseArgs({ + options: { + "base-url": { type: "string", default: "https://solon.orangecat.ch" }, + session: { type: "string" }, + choice: { type: "string" }, + "key-env": { type: "string", default: "SOLON_AGENT_PRIVKEY" }, + }, +}); + +async function main() { + const { session, choice } = values; + if (!session || !choice) { + console.error("required: --session --choice yes|no|abstain"); + process.exit(1); + } + if (!["yes", "no", "abstain"].includes(choice)) { + console.error(`--choice must be yes, no or abstain, got ${choice}`); + process.exit(1); + } + const keyEnv = values["key-env"] ?? "SOLON_AGENT_PRIVKEY"; + const privateKeyHex = process.env[keyEnv]; + if (!privateKeyHex) { + console.error(`env ${keyEnv} is not set — the agent's private key lives in its own env, nowhere else`); + process.exit(1); + } + + const publicKeyHex = secp.etc.bytesToHex(secp.getPublicKey(privateKeyHex, true)); + const address = deriveAddresses(publicKeyHex).p2pkh; + const message = voteMessage({ sessionId: session, choice, memberAddress: address }); + const signature = signMessage(message, privateKeyHex); + + console.log(`voting as ${address} on session ${session}: ${choice}`); + const res = await fetch(`${values["base-url"]}/api/sessions/${session}/votes`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ choice, address, signature }), + }); + const verdict = await res.json(); + console.log(JSON.stringify(verdict, null, 2)); + if (!verdict.stored) process.exit(1); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/app/api/proposals/[proposalId]/open/route.ts b/src/app/api/proposals/[proposalId]/open/route.ts new file mode 100644 index 0000000..c1b8c3e --- /dev/null +++ b/src/app/api/proposals/[proposalId]/open/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { openSession } from "@/lib/domain/voting"; + +/** + * Open the voting session for a DRAFT proposal. Deliberately permissionless + * in v1: the proposal is already signed and public, opening only starts the + * clock, and it can happen exactly once (one session per proposal). The + * rules — electorate, threshold, quorum, eligibility — are snapshotted here. + */ +export async function POST(_: Request, { params }: { params: { proposalId: string } }) { + try { + const session = await openSession(params.proposalId); + return NextResponse.json({ opened: true, session }, { status: 201 }); + } catch (e) { + const message = e instanceof Error ? e.message : "failed to open session"; + return NextResponse.json({ opened: false, error: message }, { status: message.includes("not found") ? 404 : 409 }); + } +} diff --git a/src/app/api/proposals/route.ts b/src/app/api/proposals/route.ts new file mode 100644 index 0000000..0590cdb --- /dev/null +++ b/src/app/api/proposals/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { DecisionCategory } from "@prisma/client"; +import { createProposal } from "@/lib/domain/proposals"; + +const BodySchema = z.object({ + orgSlug: z.string().min(1).max(100), + category: z.enum(DecisionCategory), + title: z.string().min(3).max(200), + body: z.string().min(1).max(20000), + policyKey: z.string().min(1).max(100).optional(), + proposedContent: z.unknown().optional(), + target: z.string().max(200).optional(), + proposerAddress: z.string().min(20).max(90), + signature: z.string().min(1).max(200), +}); + +/** + * File a proposal. Authorization is the Bitcoin signature over + * proposalMessage() — see lib/domain/proposals.ts. Agent members must also + * present their transport API key as `Authorization: Bearer sk_solon_…`. + */ +export async function POST(req: Request) { + const parsed = BodySchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json( + { error: "invalid request body", details: z.flattenError(parsed.error).fieldErrors }, + { status: 400 }, + ); + } + + const auth = req.headers.get("authorization"); + const apiKey = auth?.startsWith("Bearer ") ? auth.slice("Bearer ".length) : null; + + const result = await createProposal({ ...parsed.data, apiKey }); + if (!result.created) { + // 401 when the signature itself failed; 422 when it verified but the + // proposer/org/key wasn't eligible. + return NextResponse.json(result, { status: result.verified ? 422 : 401 }); + } + return NextResponse.json(result, { status: 201 }); +} diff --git a/src/app/api/sessions/[sessionId]/close/route.ts b/src/app/api/sessions/[sessionId]/close/route.ts new file mode 100644 index 0000000..f013ac6 --- /dev/null +++ b/src/app/api/sessions/[sessionId]/close/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { closeSession } from "@/lib/domain/voting"; + +/** + * 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. + */ +export async function POST(_: Request, { params }: { params: { sessionId: string } }) { + try { + const result = await closeSession(params.sessionId); + return NextResponse.json({ closed: true, outcome: result.outcome, tally: result.tally }); + } catch (e) { + const message = e instanceof Error ? e.message : "failed to close session"; + return NextResponse.json({ closed: false, error: message }, { status: message.includes("not found") ? 404 : 409 }); + } +} diff --git a/src/app/api/sessions/[sessionId]/route.ts b/src/app/api/sessions/[sessionId]/route.ts new file mode 100644 index 0000000..1f62e6a --- /dev/null +++ b/src/app/api/sessions/[sessionId]/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { sessionTally } from "@/lib/domain/voting"; + +/** Public read: a voting session, its snapshotted rules, and the live tally. */ +export async function GET(_: Request, { params }: { params: { sessionId: string } }) { + const session = await prisma.votingSession.findUnique({ + where: { id: params.sessionId }, + include: { + proposal: { + select: { id: true, title: true, category: true, policyKey: true, contentHash: true, status: true }, + }, + }, + }); + if (!session) return NextResponse.json({ error: "voting session not found" }, { status: 404 }); + + return NextResponse.json({ + id: session.id, + status: session.status, + opensAt: session.opensAt, + closesAt: session.closesAt, + electorate: session.electorate, + threshold: session.threshold, + quorumPercent: session.quorumPercent, + eligibleCount: session.eligibleCount, + eligibleWeight: Number(session.eligibleWeight), + outcome: session.outcome, + proposal: session.proposal, + tally: await sessionTally(session.id), + }); +} diff --git a/src/app/api/sessions/[sessionId]/votes/route.ts b/src/app/api/sessions/[sessionId]/votes/route.ts new file mode 100644 index 0000000..e689f29 --- /dev/null +++ b/src/app/api/sessions/[sessionId]/votes/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { submitVote } from "@/lib/domain/voting"; + +const BodySchema = z.object({ + choice: z.enum(["yes", "no", "abstain"]), + address: z.string().min(20).max(90), + signature: z.string().min(1).max(200), +}); + +/** + * Cast a cryptographically-signed vote. The body carries the member's Bitcoin + * address and a Bitcoin signed-message signature over the canonical vote + * message (see lib/bitcoin/message.ts). The server verifies the signature; an + * invalid one is rejected and never stored. No transport auth: the signature + * IS the authorization, and votes are public record anyway. + */ +export async function POST(req: Request, { params }: { params: { sessionId: string } }) { + const parsed = BodySchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json( + { error: "invalid request body", details: z.flattenError(parsed.error).fieldErrors }, + { status: 400 }, + ); + } + + const result = await submitVote(params.sessionId, parsed.data); + if (!result.stored) { + // 401 when the signature itself failed; 422 when it verified but the + // voter/session wasn't eligible. + return NextResponse.json(result, { status: result.verified ? 422 : 401 }); + } + return NextResponse.json(result); +} diff --git a/src/app/api/voting/[sessionId]/cryptographic-vote/route.ts b/src/app/api/voting/[sessionId]/cryptographic-vote/route.ts deleted file mode 100644 index 9e20257..0000000 --- a/src/app/api/voting/[sessionId]/cryptographic-vote/route.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { NextResponse } from "next/server"; -import { submitVote, sessionTally, type SubmitVoteInput } from "@/lib/domain/voting"; - -const CHOICES = ["yes", "no", "abstain"] as const; - -/** - * Cast a cryptographically-signed vote. The body must carry the member's - * Bitcoin address and a Bitcoin signed-message signature over the canonical - * vote message (see lib/bitcoin/message.ts). The server verifies the - * signature; an invalid one is rejected and never stored. - * - * Body: { choice: 'yes'|'no'|'abstain', address: string, signature: string } - */ -export async function POST(req: Request, { params }: { params: { sessionId: string } }) { - const { sessionId } = params; - const body = await req.json().catch(() => ({})); - const { choice, address, signature } = (body ?? {}) as Partial; - - if (!choice || !address || !signature) { - return NextResponse.json({ error: "choice, address and signature are required" }, { status: 400 }); - } - if (!CHOICES.includes(choice)) { - return NextResponse.json({ error: `choice must be one of ${CHOICES.join(", ")}` }, { status: 400 }); - } - - const result = await submitVote(sessionId, { address, choice, signature }); - - if (!result.stored) { - // 401 when the signature itself failed; 422 when it verified but the - // voter/session wasn't eligible. - return NextResponse.json(result, { status: result.verified ? 422 : 401 }); - } - return NextResponse.json(result); -} - -export async function GET(_: Request, { params }: { params: { sessionId: string } }) { - const tally = await sessionTally(params.sessionId); - return NextResponse.json({ sessionId: params.sessionId, tally }); -} diff --git a/src/app/integration/page.tsx b/src/app/integration/page.tsx index 3d4cb97..8b87cc8 100644 --- a/src/app/integration/page.tsx +++ b/src/app/integration/page.tsx @@ -26,7 +26,7 @@ export default function IntegrationPage() {
voter:<your-bitcoin-address>

# Submit the signed vote
-
curl -X POST /api/voting/<sessionId>/cryptographic-vote \
+
curl -X POST /api/sessions/<sessionId>/votes \
-H "Content-Type: application/json" \
-d '{'{'}"choice":"yes","address":"1...","signature":"<base64>"{'}'}' @@ -44,15 +44,33 @@ export default function IntegrationPage() {
  • - POST /api/voting/[sessionId]/cryptographic-vote + POST /api/proposals + + file a signed proposal +
  • +
  • + + POST /api/proposals/[proposalId]/open + + open the voting session +
  • +
  • + + GET /api/sessions/[sessionId] + + session, snapshotted rules, live tally +
  • +
  • + + POST /api/sessions/[sessionId]/votes cast a signed vote
  • - GET /api/voting/[sessionId]/cryptographic-vote + POST /api/sessions/[sessionId]/close - weighted tally for a session + close after the window and decide the outcome
  • diff --git a/src/components/dashboard/voting-interface.tsx b/src/components/dashboard/voting-interface.tsx index a29749b..966666c 100644 --- a/src/components/dashboard/voting-interface.tsx +++ b/src/components/dashboard/voting-interface.tsx @@ -42,7 +42,7 @@ export default function VotingInterface({ session, tally }: VotingInterfaceProps setSubmitting(true); setVerdict(null); try { - const res = await fetch(`/api/voting/${session.id}/cryptographic-vote`, { + const res = await fetch(`/api/sessions/${session.id}/votes`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ choice, address, signature }), diff --git a/src/lib/domain/__tests__/canonical.test.ts b/src/lib/domain/__tests__/canonical.test.ts new file mode 100644 index 0000000..453c735 --- /dev/null +++ b/src/lib/domain/__tests__/canonical.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { canonicalJson, contentHashOf } from "../canonical"; + +describe("canonical JSON (cross-system contract)", () => { + it("sorts keys recursively, keeps array order, drops undefined", () => { + expect(canonicalJson({ b: 1, a: { d: [3, 2], c: null }, e: "x", skip: undefined })).toBe( + '{"a":{"c":null,"d":[3,2]},"b":1,"e":"x"}', + ); + }); + + it("is insensitive to key insertion order", () => { + expect(contentHashOf({ x: 1, y: 2 })).toBe(contentHashOf({ y: 2, x: 1 })); + }); + + // Golden hashes. OrangeCat re-hashes proposed policy content with its own + // implementation of these rules; if either of these values changes, the + // cross-repo contentHash contract is broken and decision verification on + // the OC side will refuse every new policy version. + it("matches the golden hashes", () => { + expect(contentHashOf({ b: 1, a: { d: [3, 2], c: null }, e: "x" })).toBe( + "27411d33a050271292b9ea2eeb27d7271258c3fa82265c9fcf4ee0b0a58ca3d9", + ); + expect(contentHashOf({ max_cat_daily_spend_btc: 0.002, max_cat_btc_per_action: 0.0005 })).toBe( + "7db0d28f49cd8821e7bfd739af9af851ed944b9c4189d6e214760a7272a272f9", + ); + }); +}); diff --git a/src/lib/domain/__tests__/tally.test.ts b/src/lib/domain/__tests__/tally.test.ts index 50fe12a..38cc2b2 100644 --- a/src/lib/domain/__tests__/tally.test.ts +++ b/src/lib/domain/__tests__/tally.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { SessionOutcome, VoteChoice, VoteThreshold } from "@prisma/client"; -import { decideOutcome, tally } from "../tally"; +import { closeRefusal, decideOutcome, tally } from "../tally"; describe("tally", () => { it("weights votes by member weight", () => { @@ -62,6 +62,21 @@ describe("decideOutcome", () => { ).toBe(SessionOutcome.REJECTED); }); + it("refuses to close early unless every eligible member has voted", () => { + const closesAt = new Date("2026-08-14T00:00:00Z"); + const before = new Date("2026-08-10T00:00:00Z"); + // Window open, a voter missing: refuse with the reason. + expect(closeRefusal({ now: before, closesAt, votesCast: 2, eligibleCount: 3 })).toMatch( + /voting window is open/, + ); + // Window open but everyone has spoken: nothing left to wait for. + expect(closeRefusal({ now: before, closesAt, votesCast: 3, eligibleCount: 3 })).toBeNull(); + // Window elapsed: closable regardless of turnout. + expect( + closeRefusal({ now: new Date("2026-08-14T00:00:01Z"), closesAt, votesCast: 0, eligibleCount: 3 }), + ).toBeNull(); + }); + it("expires on zero eligible weight — an empty electorate decides nothing", () => { expect( decideOutcome({ diff --git a/src/lib/domain/__tests__/vote-spine.integration.test.ts b/src/lib/domain/__tests__/vote-spine.integration.test.ts new file mode 100644 index 0000000..b323d4b --- /dev/null +++ b/src/lib/domain/__tests__/vote-spine.integration.test.ts @@ -0,0 +1,217 @@ +/** + * Full vote-spine acceptance against a real database: propose → open → vote → + * close → policy activation, plus every gate on the way (agent transport auth, + * electorate rules, early-close refusal, quorum). + * + * Runs only with INTEGRATION=1 and a DATABASE_URL whose schema is migrated + * (CI: postgres service container + `prisma migrate deploy` — which also + * proves the migrations replay on a fresh database). Plain `npm test` skips it. + */ +import { describe, expect, it } from "vitest"; +import { randomUUID } from "node:crypto"; +import { + AuditEventType, + DecisionCategory, + PolicyStatus, + SessionOutcome, +} from "@prisma/client"; +import { prisma } from "@/lib/db"; +import { generateKeyPair, proposalMessage, signMessage, voteMessage } from "@/lib/bitcoin/message"; +import { contentHashOf, sha256Hex } from "@/lib/domain/canonical"; +import { createProposal } from "@/lib/domain/proposals"; +import { closeSession, openSession, submitVote } from "@/lib/domain/voting"; + +const RUN = process.env.INTEGRATION === "1"; + +describe.runIf(RUN)("vote spine (database integration)", () => { + it("carries a policy proposal from signature to activated version", async () => { + const slug = `it-${randomUUID().slice(0, 8)}`; + const org = await prisma.organization.create({ + data: { slug, name: "Integration Test Org" }, + }); + // Bootstrap policy v1 — the version the vote will supersede. + await prisma.policy.create({ + data: { + organizationId: org.id, + key: "allocation_policy", + version: 1, + content: { max_cat_daily_spend_btc: 0.001 }, + status: PolicyStatus.ACTIVE, + }, + }); + + const alice = generateKeyPair(); + const bob = generateKeyPair(); + const agent = generateKeyPair(); + for (const [name, pair, type, system] of [ + ["Alice", alice, "HUMAN", null], + ["Bob", bob, "HUMAN", null], + ["Cat", agent, "AGENT", "orangecat:cat"], + ] as const) { + await prisma.member.create({ + data: { + organizationId: org.id, + displayName: name, + memberType: type, + keyCustody: type === "HUMAN" ? "SELF" : "SERVICE", + bitcoinAddress: pair.address, + publicKeyHex: pair.publicKeyHex, + system, + }, + }); + } + + // --- Propose (agent, with content binding and transport auth) --- + const proposedContent = { max_cat_daily_spend_btc: 0.002, max_cat_btc_per_action: 0.0005 }; + const contentHash = contentHashOf(proposedContent); + const base = { + orgSlug: slug, + category: DecisionCategory.ALLOCATION_POLICY, + title: "Raise the Cat's ceiling", + body: "Double the daily ceiling.", + policyKey: "allocation_policy", + proposedContent, + proposerAddress: agent.address, + signature: signMessage( + proposalMessage({ + orgSlug: slug, + category: DecisionCategory.ALLOCATION_POLICY, + title: "Raise the Cat's ceiling", + proposerAddress: agent.address, + contentHash, + }), + agent.privateKeyHex, + ), + }; + + // Agent without its API key: signature verifies, transport refused. + const noKey = await createProposal(base); + expect(noKey).toMatchObject({ created: false, verified: true }); + expect(noKey.reason).toMatch(/API key/); + + const agentMember = await prisma.member.findFirstOrThrow({ + where: { organizationId: org.id, bitcoinAddress: agent.address }, + }); + const apiKey = `sk_solon_test_${randomUUID()}`; + await prisma.agentApiKey.create({ data: { memberId: agentMember.id, keyHash: sha256Hex(apiKey) } }); + + const filed = await createProposal({ ...base, apiKey }); + expect(filed.created).toBe(true); + expect(filed.contentHash).toBe(contentHash); + const proposalId = filed.proposalId!; + + // --- Open: rules snapshotted, all 3 members eligible --- + const session = await openSession(proposalId); + expect(session.electorate).toBe("ALL_MEMBERS"); + expect(session.eligibleCount).toBe(3); + + // --- Vote: two humans + the agent, each with their own signature --- + const cast = async (pair: typeof alice, choice: "yes" | "no" | "abstain") => + submitVote(session.id, { + address: pair.address, + choice, + signature: signMessage( + voteMessage({ sessionId: session.id, choice, memberAddress: pair.address }), + pair.privateKeyHex, + ), + }); + + expect((await cast(alice, "yes")).stored).toBe(true); + expect((await cast(agent, "yes")).stored).toBe(true); + + // --- Early close refused while the window is open and a voter is missing --- + await expect(closeSession(session.id)).rejects.toThrow(/voting window is open/); + + const bobVote = await cast(bob, "no"); + expect(bobVote.stored).toBe(true); + expect(bobVote.tally).toEqual({ yes: 2, no: 1, abstain: 0 }); + + // --- Close: full participation allows closing early; 2:1 approves --- + const closed = await closeSession(session.id); + expect(closed.outcome).toBe(SessionOutcome.APPROVED); + + // --- Policy v2 exists, references the session, v1 superseded --- + const v2 = await prisma.policy.findUniqueOrThrow({ + where: { organizationId_key_version: { organizationId: org.id, key: "allocation_policy", version: 2 } }, + }); + expect(v2.status).toBe(PolicyStatus.ACTIVE); + expect(v2.approvedBySessionId).toBe(session.id); + expect(v2.content).toEqual(proposedContent); + const v1 = await prisma.policy.findUniqueOrThrow({ + where: { organizationId_key_version: { organizationId: org.id, key: "allocation_policy", version: 1 } }, + }); + expect(v1.status).toBe(PolicyStatus.SUPERSEDED); + + // --- The audit trail recorded every step --- + const events = await prisma.auditEvent.findMany({ + where: { organizationId: org.id }, + select: { eventType: true }, + }); + const types = events.map((e) => e.eventType); + for (const expected of [ + AuditEventType.PROPOSAL_CREATED, + AuditEventType.SESSION_OPENED, + AuditEventType.VOTE_CAST, + AuditEventType.SESSION_CLOSED, + AuditEventType.POLICY_ACTIVATED, + ]) { + expect(types).toContain(expected); + } + }); + + it("keeps agents out of HUMANS_ONLY sessions", async () => { + const slug = `it-${randomUUID().slice(0, 8)}`; + const org = await prisma.organization.create({ data: { slug, name: "Humans Only Org" } }); + const human = generateKeyPair(); + const agent = generateKeyPair(); + for (const [name, pair, type] of [ + ["Human", human, "HUMAN"], + ["Agent", agent, "AGENT"], + ] as const) { + await prisma.member.create({ + data: { + organizationId: org.id, + displayName: name, + memberType: type, + keyCustody: type === "HUMAN" ? "SELF" : "SERVICE", + bitcoinAddress: pair.address, + publicKeyHex: pair.publicKeyHex, + system: type === "AGENT" ? "test:agent" : null, + }, + }); + } + + const filed = await createProposal({ + orgSlug: slug, + category: DecisionCategory.MEMBERSHIP, + title: "Admit a new member", + body: "Roster change — humans only.", + proposerAddress: human.address, + signature: signMessage( + proposalMessage({ + orgSlug: slug, + category: DecisionCategory.MEMBERSHIP, + title: "Admit a new member", + proposerAddress: human.address, + }), + human.privateKeyHex, + ), + }); + expect(filed.created).toBe(true); + + const session = await openSession(filed.proposalId!); + expect(session.electorate).toBe("HUMANS_ONLY"); + expect(session.eligibleCount).toBe(1); // the agent is not in the electorate + + const agentVote = await submitVote(session.id, { + address: agent.address, + choice: "yes", + signature: signMessage( + voteMessage({ sessionId: session.id, choice: "yes", memberAddress: agent.address }), + agent.privateKeyHex, + ), + }); + expect(agentVote).toMatchObject({ stored: false, verified: true }); + expect(agentVote.reason).toMatch(/humans-only/); + }); +}); diff --git a/src/lib/domain/canonical.ts b/src/lib/domain/canonical.ts new file mode 100644 index 0000000..ddfe803 --- /dev/null +++ b/src/lib/domain/canonical.ts @@ -0,0 +1,28 @@ +import { sha256 } from "@noble/hashes/sha256"; +import { bytesToHex } from "@noble/hashes/utils"; + +/** + * Canonical JSON: recursively key-sorted, no whitespace, `undefined` object + * values dropped. This is the cross-system contract behind `contentHash` — + * OrangeCat re-hashes proposed policy content with the same rules to check a + * decision document, so any change here is a breaking protocol change + * (guarded by a golden-hash unit test). + */ +export function canonicalJson(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map((v) => canonicalJson(v)).join(",")}]`; + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`; +} + +/** sha256 hex of a UTF-8 string. */ +export function sha256Hex(text: string): string { + return bytesToHex(sha256(new TextEncoder().encode(text))); +} + +/** The contentHash voters sign over: sha256 of the canonical JSON. */ +export function contentHashOf(content: unknown): string { + return sha256Hex(canonicalJson(content)); +} diff --git a/src/lib/domain/proposals.ts b/src/lib/domain/proposals.ts new file mode 100644 index 0000000..e4a6e91 --- /dev/null +++ b/src/lib/domain/proposals.ts @@ -0,0 +1,131 @@ +import { + AuditEventType, + DecisionCategory, + MemberStatus, + MemberType, + Prisma, +} from "@prisma/client"; +import { prisma } from "@/lib/db"; +import { proposalMessage, verifyMessage } from "@/lib/bitcoin/message"; +import { contentHashOf, sha256Hex } from "@/lib/domain/canonical"; + +export interface CreateProposalInput { + orgSlug: string; + category: DecisionCategory; + title: string; + /** Markdown rationale. Not signature-bound — the binding artifacts are title + contentHash. */ + body: string; + policyKey?: string | null; + proposedContent?: unknown; + target?: string | null; + proposerAddress: string; + /** Bitcoin signed-message signature over proposalMessage() by the proposer. */ + signature: string; + /** Transport auth, required for AGENT proposers (`sk_solon_…`). */ + apiKey?: string | null; +} + +export interface CreateProposalResult { + created: boolean; + verified: boolean; + reason?: string; + proposalId?: string; + contentHash?: string; +} + +/** + * File a proposal. The Bitcoin signature is the authorization: the proposer is + * whoever the signature recovers to, and for policy proposals the signature + * binds the sha256 of the exact proposed content — nothing can be swapped + * after signing. Agents may propose in ANY category (the electorate only gates + * voting), but must additionally present their transport API key. + */ +export async function createProposal(input: CreateProposalInput): Promise { + const hasPolicyKey = input.policyKey != null && input.policyKey !== ""; + const hasContent = input.proposedContent !== undefined; + if (hasPolicyKey !== hasContent) { + return { + created: false, + verified: false, + reason: "policyKey and proposedContent must be provided together — a policy change needs both", + }; + } + + const org = await prisma.organization.findUnique({ where: { slug: input.orgSlug } }); + if (!org) return { created: false, verified: false, reason: "organization not found" }; + + const contentHash = hasContent ? contentHashOf(input.proposedContent) : null; + const message = proposalMessage({ + orgSlug: input.orgSlug, + category: input.category, + title: input.title, + proposerAddress: input.proposerAddress, + contentHash, + }); + const verification = verifyMessage(message, input.proposerAddress, input.signature); + if (!verification.valid) { + return { + created: false, + verified: false, + reason: verification.reason ?? "signature does not match proposer address", + }; + } + + const member = await prisma.member.findFirst({ + where: { + organizationId: org.id, + bitcoinAddress: input.proposerAddress, + status: MemberStatus.ACTIVE, + }, + }); + if (!member) { + return { created: false, verified: true, reason: "address is not an active member of this organization" }; + } + + if (member.memberType === MemberType.AGENT) { + if (!input.apiKey) { + return { created: false, verified: true, reason: "agent proposers must present their API key" }; + } + const key = await prisma.agentApiKey.findFirst({ + where: { memberId: member.id, keyHash: sha256Hex(input.apiKey), revokedAt: null }, + }); + if (!key) { + return { created: false, verified: true, reason: "API key does not belong to this agent member or is revoked" }; + } + } + + const proposal = await prisma.$transaction(async (tx) => { + const p = await tx.proposal.create({ + data: { + organizationId: org.id, + category: input.category, + title: input.title, + body: input.body, + policyKey: hasPolicyKey ? input.policyKey : null, + proposedContent: hasContent ? (input.proposedContent as Prisma.InputJsonValue) : Prisma.DbNull, + target: input.target ?? null, + contentHash, + proposerMemberId: member.id, + proposerSignature: input.signature, + }, + }); + await tx.auditEvent.create({ + data: { + organizationId: org.id, + eventType: AuditEventType.PROPOSAL_CREATED, + actorMemberId: member.id, + subjectType: "proposal", + subjectId: p.id, + payload: { + category: input.category, + title: input.title, + memberType: member.memberType, + ...(contentHash ? { policyKey: input.policyKey, contentHash } : {}), + }, + }, + }); + return p; + }); + + return { created: true, verified: true, proposalId: proposal.id, ...(contentHash ? { contentHash } : {}) }; +} diff --git a/src/lib/domain/tally.ts b/src/lib/domain/tally.ts index e210b1c..0049bb7 100644 --- a/src/lib/domain/tally.ts +++ b/src/lib/domain/tally.ts @@ -28,6 +28,23 @@ export function tally(votes: WeightedVote[]): Tally { * Abstain counts toward quorum (the member showed up) but not toward the * threshold (it is not a yes and not a no). */ +/** + * May a session be closed now? Closing early would cut voting short — an + * attacker could stack a tally and slam the door — so a session only closes + * once the window has elapsed, or once every eligible member has already + * spoken (nothing left to wait for). Returns the refusal reason, or null. + */ +export function closeRefusal(params: { + now: Date; + closesAt: Date; + votesCast: number; + eligibleCount: number; +}): string | null { + if (params.now >= params.closesAt) return null; + if (params.eligibleCount > 0 && params.votesCast >= params.eligibleCount) return null; + return `voting window is open until ${params.closesAt.toISOString()} and only ${params.votesCast} of ${params.eligibleCount} eligible members have voted`; +} + export function decideOutcome(params: { tally: Tally; threshold: VoteThreshold; diff --git a/src/lib/domain/voting.ts b/src/lib/domain/voting.ts index 6806625..73a1db5 100644 --- a/src/lib/domain/voting.ts +++ b/src/lib/domain/voting.ts @@ -18,7 +18,7 @@ import { CATEGORY_THRESHOLD, VOTING_WINDOW_DAYS, } from "@/lib/config/governance"; -import { tally, decideOutcome, type Tally } from "@/lib/domain/tally"; +import { tally, decideOutcome, closeRefusal, type Tally } from "@/lib/domain/tally"; export interface SubmitVoteInput { address: string; @@ -209,6 +209,15 @@ export async function closeSession(sessionId: string) { if (!session) throw new Error("voting session not found"); if (session.status !== SessionStatus.ACTIVE) throw new Error(`session already ${session.status}`); + const votesCast = await prisma.vote.count({ where: { sessionId } }); + const refusal = closeRefusal({ + now: new Date(), + closesAt: session.closesAt, + votesCast, + eligibleCount: session.eligibleCount, + }); + if (refusal) throw new Error(refusal); + const t = await sessionTally(sessionId); const outcome = decideOutcome({ tally: t,