diff --git a/.env.example b/.env.example index 660900b..088dc16 100644 --- a/.env.example +++ b/.env.example @@ -28,13 +28,34 @@ AUTH_REFRESH_TOKEN_TTL_SECONDS=1209600 AUTH_CHALLENGE_TTL_SECONDS=300 AUTH_COOKIE_DOMAIN= AUTH_COOKIE_SECURE=true +PROPOSAL_COMMENT_WRITE_RATE_LIMIT_CACHE=10000 +PROPOSAL_COMMENT_WRITE_RATE_LIMIT_MAX=5 +PROPOSAL_COMMENT_WRITE_RATE_LIMIT_WINDOW_SECONDS=60 HIVE_RPC_URL=https://api.hive.blog HIVE_AUTH_AUDIENCE=hivelore-local-api +HIVE_MAINNET_CHAIN_ID=beeab0de00000000000000000000000000000000000000000000000000000000 +HIVE_MAINNET_RPC_NODES=https://api.hive.blog +HIVE_MAINNET_HAF_URL=https://api.hive.blog/hafbe-api +HIVE_BROADCAST_MAX_ATTEMPTS=4 +HIVE_BROADCAST_INITIAL_DELAY_MS=500 +HIVE_BROADCAST_MAX_DELAY_MS=5000 +HIVE_BROADCAST_BACKOFF_MULTIPLIER=2 +HIVE_BROADCAST_JITTER_RATIO=0.2 +HIVE_BROADCAST_TIMEOUT_MS=10000 +HIVE_BROADCAST_TOTAL_DEADLINE_MS=90000 +HIVE_CONFIRMATION_POLL_INTERVAL_MS=3000 +HIVE_CONFIRMATION_TIMEOUT_MS=60000 +HIVE_NODE_MAX_CONSECUTIVE_FAILURES=1 +HIVE_NODE_COOLDOWN_MS=30000 + +# Local-only irreversible Hive mainnet smoke command inputs. +# Set these only in your local shell when running the smoke script; never commit real keys. +HIVE_SMOKE_ACCOUNT= GOOGLE_AUTH_ENABLED=false GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= -GOOGLE_REDIRECT_URI= +GOOGLE_REDIRECT_URI=http://localhost:3001/auth/google/callback GOOGLE_HIVE_PROVISIONING_ENABLED=false HIVE_RC_DELEGATION_ENABLED=false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d0a4c0..807dcd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,13 +12,28 @@ permissions: env: CI: true NEXT_PUBLIC_API_URL: http://localhost:3001 - DATABASE_URL: postgresql://USER:PASSWORD@POOLER_HOST:6543/postgres?pgbouncer=true - DIRECT_URL: postgresql://USER:PASSWORD@DIRECT_HOST:5432/postgres + DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres + DIRECT_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres jobs: quality: name: Quality runs-on: ubuntu-latest + timeout-minutes: 20 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - name: Checkout @@ -33,9 +48,6 @@ jobs: - name: Install dependencies run: npm ci - - name: Check formatting - run: npm run format:check - - name: Format Prisma schema run: npm run db:format @@ -45,11 +57,37 @@ jobs: - name: Generate Prisma Client run: npm run db:generate - - name: Lint - run: npm run lint + - name: Check fresh PostgreSQL migrations + run: npm run db:migrate:check:fresh + env: + TEST_DATABASE_ADMIN_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres + + - name: Check PostgreSQL migration upgrade from develop + run: | + git fetch origin develop --depth=1 + mkdir -p /tmp/hivelore-develop + git archive origin/develop apps/api/prisma | tar -x -C /tmp/hivelore-develop + npm run db:migrate:check:upgrade + env: + TEST_DATABASE_ADMIN_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres + TEST_DATABASE_BASE_PRISMA_SCHEMA: /tmp/hivelore-develop/apps/api/prisma/schema.prisma - name: Type-check run: npm run typecheck + - name: Test + run: npm test + env: + NODE_ENV: test + AUTH_JWT_SECRET: test-only-jwt-secret-with-enough-entropy + AUTH_REFRESH_SECRET: test-only-refresh-secret-with-enough-entropy + TEST_DATABASE_ADMIN_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres + + - name: Lint + run: npm run lint + + - name: Check formatting + run: npm run format:check + - name: Build run: npm run build diff --git a/.gitignore b/.gitignore index 4155036..845cd4f 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ build/Release # Dependency directories node_modules/ jspm_packages/ +.worktrees/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ diff --git a/.prettierignore b/.prettierignore index 168b846..9acb5e5 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,5 @@ node_modules +.worktrees .next dist coverage diff --git a/README.md b/README.md index d556e96..453a0d9 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,7 @@ Stores mutable application state optimized for querying. - Drafts - Proposals +- Proposal comments - AI reports - Lore graph - Search index @@ -243,6 +244,58 @@ Default MVP: These rules provide basic protection against Sybil attacks while keeping governance simple. +### Canon Voting MVP Rule + +Submitted contributions enter `VOTING` immediately with `votingStartedAt` set by the backend and +`votingEndsAt = votingStartedAt + 48 hours`. The voting service never accepts client-supplied +voter identity, role, tally, result, or authoritative timestamps. Authenticated world members with +`VOTE_ON_PROPOSAL` may cast one vote per proposal and may change that vote only before +`votingEndsAt`. + +Supported choices are `APPROVE`, `REJECT`, `NEEDS_REVISION`, and `ALTERNATE_TIMELINE`. +`totalVotes` counts all four choices. Approval arithmetic is exact integer basis points: +`approvalNumerator = APPROVE`, `approvalDenominator = all four choices`, and +`approvalPercentageBps = floor(APPROVE * 10000 / approvalDenominator)`, or `0` when the denominator +is zero. `NEEDS_REVISION` and `ALTERNATE_TIMELINE` count toward both participation and the approval +denominator. + +The MVP policy is centralized in `apps/api/src/lib/canon-voting-policy.ts`: + +- Minimum eligible internal votes: 5. +- Approval threshold: 7000 basis points, so exactly 70% passes. +- Voting window: 48 hours. +- A proposal can pass only after the full window has ended. +- Founder or author status never bypasses the rule. +- A major AI warning remains attached and visible; it does not automatically reject a proposal. + +If the approval rule does not pass after the window closes, the deterministic terminal outcome is +the plurality among `NEEDS_REVISION`, `ALTERNATE_TIMELINE`, and `REJECT`; ties resolve in that order. +If no failed-outcome choice has votes, the result is `REJECTED`. If the proposal's recorded base canon +version is stale at decision time, the outcome is `STALE_BASE_CONFLICT` and the proposal is surfaced +as needing revision instead of silently applying to newer canon. + +Finalization creates one immutable `ProposalDecision` snapshot with per-choice counts, threshold +version, content hash, AI-warning state, branch/conflict metadata, and a deterministic decision +payload hash. Passing governance sets the proposal to `APPROVED_FOR_PUBLICATION`; it does not make a +`LoreEntry` `PUBLISHED_CANON`. Published canon still requires a separately confirmed Hive lore +post/comment and indexed `HiveReference`. + +Approved decisions use a non-custodial Hive signing handoff. The API returns an unsigned posting +authority `custom_json` operation with HiveLore's stable custom JSON id (`hivelore`), schema version, +world ID, proposal ID, content hash, outcome, timestamps, tally, thresholds, AI-warning state, and +branch/base-canon references. The contributor signs and broadcasts through Hive Keychain or +HiveSigner; HiveLore never requests or stores private keys. + +When a client reports broadcast details, the backend retrieves the operation through the configured +HAF/Hive indexing abstraction and verifies confirmation, operation type, custom JSON id, posting +signer, world/proposal/decision IDs, frozen tally/payload hash, timestamps, and duplicate operation +state before linking the result to `HiveEvent` and `ProposalDecision` idempotently. + +Branching metadata keeps alternate continuations readable without deleting competing branches. +Alternate-timeline decisions are excluded from canon-only projections; canonizing one branch does not +remove other branches, which can later be revised, archived, merged, or retained under moderation +permissions. + ## Hive Voting Hive votes determine: @@ -336,6 +389,13 @@ Implemented contribution endpoints: - `DELETE /worlds/:worldId/contributions/:contributionId`: requires `EDIT_OWN_DRAFT`, deletes only draft contributions, and never deletes submitted proposals. - `POST /worlds/:worldId/contributions/:contributionId/submit`: requires `SUBMIT_PROPOSAL`, atomically locks the draft, creates exactly one submitted proposal with an immutable `proposedContent` snapshot, and does not publish to Hive. +Implemented proposal discussion endpoints: + +- `GET /worlds/:worldId/proposals/:proposalId/comments`: lists flat chronological off-chain proposal comments with cursor pagination. The proposal must belong to the route world. Deleted comments are returned as tombstones without the original body. +- `POST /worlds/:worldId/proposals/:proposalId/comments`: requires authentication and the existing `VOTE_ON_PROPOSAL` world permission, derives the author from the verified session, trims plain-text body content, enforces a 3,000-character maximum, and rate-limits comment writes to 5 per verified user per minute by default. + +Proposal comments are stored in PostgreSQL as mutable discussion records. They are not Hive comments, not immutable canon records, and never count as AppVote rows, approval totals, proposal outcomes, reputation, rewards, or canon status. Soft deletion preserves the discussion audit shape while normal API responses hide moderated comment bodies. Comment write rate limits use the existing PostgreSQL-backed Fastify rate-limit store in the API app, so limits are shared across API instances that use the same database. + --- # Reputation & Rewards @@ -514,6 +574,20 @@ HIVE_AUTH_AUDIENCE="hivelore-local-api" HIVE_RPC_URL="https://api.hive.blog" HAF_API_URL="https://api.hive.blog/hafbe-api" HIVELORE_APP_ID="hivelore/0.1.0" +HIVE_MAINNET_CHAIN_ID="beeab0de00000000000000000000000000000000000000000000000000000000" +HIVE_MAINNET_RPC_NODES="https://api.hive.blog" +HIVE_MAINNET_HAF_URL="https://api.hive.blog/hafbe-api" +HIVE_BROADCAST_MAX_ATTEMPTS=4 +HIVE_BROADCAST_INITIAL_DELAY_MS=500 +HIVE_BROADCAST_MAX_DELAY_MS=5000 +HIVE_BROADCAST_BACKOFF_MULTIPLIER=2 +HIVE_BROADCAST_JITTER_RATIO=0.2 +HIVE_BROADCAST_TIMEOUT_MS=10000 +HIVE_BROADCAST_TOTAL_DEADLINE_MS=90000 +HIVE_CONFIRMATION_POLL_INTERVAL_MS=3000 +HIVE_CONFIRMATION_TIMEOUT_MS=60000 +HIVE_NODE_MAX_CONSECUTIVE_FAILURES=1 +HIVE_NODE_COOLDOWN_MS=30000 # Optional Google-to-Hive linking and onboarding. Disabled for the base MVP. GOOGLE_AUTH_ENABLED=false @@ -593,6 +667,29 @@ Review generated SQL under `apps/api/prisma/migrations`, commit the schema and m Feature branches must include migration files. Resolve migration conflicts before merge. +### Baseline Migration History + +`20260718131500_init_hivelore_schema` is the authoritative Prisma baseline. A later historical migration, `20260718154842_init`, accidentally duplicated the same baseline SQL and caused fresh PostgreSQL databases to fail during `prisma migrate deploy` when the second migration tried to recreate enums, tables, indexes, and foreign keys that already existed. + +The duplicate migration directory is intentionally retained, but its SQL body is a no-op. This preserves the historical migration name while allowing a new developer, CI job, preview environment, or empty hosted database to run: + +```bash +npm run db:migrate:deploy +npm run db:generate +``` + +Existing databases that already recorded both baseline migrations do not need data changes before deploying this repository repair. Prisma may warn that `20260718154842_init` was modified after it was applied because the stored checksum reflects the old duplicate SQL. That warning is expected for those environments; do not reset or recreate the database to clear it. If an environment is blocked by a failed `20260718154842_init` row after the duplicate SQL already created the baseline objects, inspect `_prisma_migrations` and the actual schema first, then run `npx prisma migrate resolve --rolled-back 20260718154842_init --schema apps/api/prisma/schema.prisma` before `npm run db:migrate:deploy`. If an environment has only the first baseline recorded, the retained no-op second migration can be applied normally. + +CI validates the full migration history against a disposable PostgreSQL database with: + +```bash +TEST_DATABASE_ADMIN_URL=postgresql://postgres:postgres@127.0.0.1:5432/postgres npm run db:migrate:check:fresh +``` + +The check creates a uniquely named `hivelore_migrate_check_*` database, runs `prisma migrate deploy` twice, compares the deployed schema with `schema.prisma`, generates Prisma Client, runs the development seed, smoke-checks representative tables/enums/constraints/indexes, and drops only the generated disposable database. + +CI also validates the upgrade path from `develop` with `npm run db:migrate:check:upgrade`. That job deploys the base branch migration history into a disposable PostgreSQL database, applies the documented `20260718154842_init` recovery when needed, deploys this branch's repaired migrations, checks for schema drift, and drops the disposable database. + ### Seed Behavior The seed at `apps/api/prisma/seed.ts` is development-only and contains fictional Hive usernames, world content, lore, one lore relationship, one submitted proposal, and an advisory AI report. It does not seed real people, credentials, Hive private keys, real blockchain transactions, or financial payout projections. @@ -603,6 +700,49 @@ Hive is authoritative for on-chain publication, authorship, rewards, beneficiary All backend Hive access goes through `apps/api/src/lib/hive`. The module uses `@hiveio/wax` for transaction build, serialization, signing handoff, and broadcast; it exposes signer abstractions for Hive Keychain and HiveSigner flows without storing private keys. HAF reads use the configurable read-only `HAF_API_URL` client and are normalized before projection into PostgreSQL. The initial default is Hive's public HAF Block Explorer endpoint, using documented HAFBE paths such as `/last-synced-block`, `/block-search`, and `/accounts/{account}/operations/comments/{permlink}`; self-hosted HAF remains deferred. +### Hive Network And Broadcast Reliability + +HiveLore is mainnet-only. It uses chain ID `beeab0de00000000000000000000000000000000000000000000000000000000`, address prefix `STM`, and the configured `HIVE_MAINNET_RPC_NODES`. The network config is built as one unit with chain ID, node list, HAF/indexer URL, address prefix, custom JSON ID, and transaction expiration policy. Empty node lists, malformed chain IDs, non-mainnet chain IDs, credential-bearing node URLs, and non-HTTPS production nodes fail startup validation. + +Backend-controlled broadcasts go through the typed reliability layer in `apps/api/src/lib/hive/broadcast-reliability.ts`. It broadcasts only already-signed transactions, never asks for private keys, and preserves the submitted transaction ID across retries. Transient RPC failures use bounded exponential backoff with jitter and node rotation; permanent transaction rejections such as invalid signatures, insufficient authority, malformed operations, expired transactions, and insufficient Resource Credits fail fast without rotating through every node. Ambiguous outcomes, including a timeout after a node may have accepted a transaction, enter confirmation polling before any retry and retry only the exact same signed transaction when still safe. + +An RPC broadcast acknowledgment is not confirmation. For the MVP, "confirmed" means included in a Hive block and located through HAF or a compatible indexing/read-back path; this is weaker than last-irreversible-block finality and is reported as block inclusion. Confirmation verifies the transaction ID, operation index when known, HiveLore operation type, `custom_json` ID, posting signer, world/proposal/decision IDs, and frozen payload hash before persistence. Client-supplied block numbers and timestamps are treated only as lookup hints; PostgreSQL state changes use chain/read-back data. Confirmation time is application time after verification, while blockchain timestamp comes from the block/indexer row. + +Successful confirmed broadcasts return a structured result containing the network, transaction ID, block number, operation indexes, blockchain timestamp, confirmation time, confirmation source, sanitized node URL when applicable, and attempt count. Timeouts return an unknown or delayed-confirmation state; they must be reconciled by retrying confirmation for the same transaction ID, not by creating a different transaction. + +### Hive Smoke Tests + +Mainnet smoke test: + +1. Build the harmless HiveLore `custom_json` operation with ID `hivelore_smoke`. +2. Use posting authority only: `required_auths` must be empty and `required_posting_auths` must contain the signing account. If active or owner authority is requested, treat it as a bug and stop. +3. Use this exact JSON payload, which contains no personal data or secrets: + +```json +{ + "app": "hivelore", + "type": "mainnet_smoke", + "version": 1, + "purpose": "broadcast_readback_verification" +} +``` + +4. Sign from the local process environment only. Never hardcode, print, persist, or commit a Hive private key. +5. Broadcast through the existing WAX client and reliability broadcaster, preserving multi-node RPC failover. +6. Confirm through `condenser_api.get_transaction`, verify transaction ID, signer, `custom_json` ID, posting authority, and payload, then read `block_num` from that transaction. +7. Call `condenser_api.get_block_header(block_num)` and use that header timestamp as the blockchain timestamp. Never use transaction expiration as the blockchain timestamp. +8. Print only the transaction ID, block number, block timestamp, RPC node used, and pass/fail verification summary after the irreversible broadcast. + +Run the real smoke test only after reviewing the account and permanence warning: + +```powershell +$env:HIVE_SMOKE_ACCOUNT = "" +$env:HIVE_SMOKE_POSTING_KEY = "" +npm run smoke:hive-mainnet +``` + +The command prompts for `BROADCAST HIVELORE SMOKE` immediately before broadcasting. Mainnet smoke tests must not be run without explicit approval of the account, exact operation, and expected permanence. + ### Standalone HAF Indexer The API workspace includes a resumable HAF sync skeleton. It reads HAF block-search pages, projects supported HiveLore operations into `HiveEvent`, and stores its durable resume point in `IndexerWatermark`. diff --git a/apps/api/.env.example b/apps/api/.env.example index cf5a90a..e9d58f8 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -21,19 +21,40 @@ AUTH_REFRESH_TOKEN_TTL_SECONDS=1209600 AUTH_CHALLENGE_TTL_SECONDS=300 AUTH_COOKIE_DOMAIN= AUTH_COOKIE_SECURE=true +PROPOSAL_COMMENT_WRITE_RATE_LIMIT_CACHE=10000 +PROPOSAL_COMMENT_WRITE_RATE_LIMIT_MAX=5 +PROPOSAL_COMMENT_WRITE_RATE_LIMIT_WINDOW_SECONDS=60 -# Hive integration defaults to public read/write endpoints for development. -# HAF_API_URL points at the public HAF Block Explorer API. -# Self-hosted HAF is deferred. +# Hive integration is mainnet-only and defaults to public read/write endpoints for development. +# The chain ID is validated positively against Hive mainnet. +# Use comma-separated RPC node lists. Never include credentials in node URLs. HIVE_RPC_URL=https://api.hive.blog HIVE_AUTH_AUDIENCE=hivelore-local-api +HIVE_MAINNET_CHAIN_ID=beeab0de00000000000000000000000000000000000000000000000000000000 +HIVE_MAINNET_RPC_NODES=https://api.hive.blog +HIVE_MAINNET_HAF_URL=https://api.hive.blog/hafbe-api HAF_API_URL=https://api.hive.blog/hafbe-api HIVELORE_APP_ID=hivelore/0.1.0 +HIVE_BROADCAST_MAX_ATTEMPTS=4 +HIVE_BROADCAST_INITIAL_DELAY_MS=500 +HIVE_BROADCAST_MAX_DELAY_MS=5000 +HIVE_BROADCAST_BACKOFF_MULTIPLIER=2 +HIVE_BROADCAST_JITTER_RATIO=0.2 +HIVE_BROADCAST_TIMEOUT_MS=10000 +HIVE_BROADCAST_TOTAL_DEADLINE_MS=90000 +HIVE_CONFIRMATION_POLL_INTERVAL_MS=3000 +HIVE_CONFIRMATION_TIMEOUT_MS=60000 +HIVE_NODE_MAX_CONSECUTIVE_FAILURES=1 +HIVE_NODE_COOLDOWN_MS=30000 + +# Local-only irreversible Hive mainnet smoke command inputs. +# Set these only in your local shell when running the smoke script; never commit real keys. +HIVE_SMOKE_ACCOUNT= GOOGLE_AUTH_ENABLED=false GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= -GOOGLE_REDIRECT_URI= +GOOGLE_REDIRECT_URI=http://localhost:3001/auth/google/callback GOOGLE_HIVE_PROVISIONING_ENABLED=false HIVE_RC_DELEGATION_ENABLED=false diff --git a/apps/api/package.json b/apps/api/package.json index 441e6cc..8e2b194 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,16 +7,19 @@ "predev": "prisma generate", "dev": "tsx watch src/server.ts", "indexer:run": "tsx src/indexer/runner.ts", + "smoke:hive-mainnet": "tsx src/scripts/hive-mainnet-smoke.ts", "prisma:generate": "prisma generate", "prisma:validate": "prisma validate", "prisma:format": "prisma format", + "prisma:migrate:check:fresh": "node scripts/check-fresh-prisma-migrations.mjs", + "prisma:migrate:check:upgrade": "node scripts/check-upgrade-prisma-migrations.mjs", "db:migrate:dev": "prisma migrate dev", "db:migrate:deploy": "prisma migrate deploy", "db:seed": "prisma db seed", "db:reset": "prisma migrate reset", "db:studio": "prisma studio", "pretest": "prisma generate", - "test": "tsx --test \"src/**/*.test.ts\"", + "test": "node scripts/run-tests.mjs", "pretypecheck": "prisma generate", "lint": "eslint .", "typecheck": "tsc --noEmit", diff --git a/apps/api/prisma/migrations/20260718154842_init/migration.sql b/apps/api/prisma/migrations/20260718154842_init/migration.sql index 8b4a0f5..184a31c 100644 --- a/apps/api/prisma/migrations/20260718154842_init/migration.sql +++ b/apps/api/prisma/migrations/20260718154842_init/migration.sql @@ -1,529 +1,9 @@ --- CreateEnum -CREATE TYPE "LoreType" AS ENUM ('CHARACTER', 'LOCATION', 'FACTION', 'EVENT', 'ARTIFACT', 'HISTORY', 'RULE', 'OTHER'); - --- CreateEnum -CREATE TYPE "LoreStatus" AS ENUM ('DRAFT', 'SUBMITTED', 'APPROVED_FOR_PUBLICATION', 'PUBLISHED_CANON', 'ARCHIVED'); - --- CreateEnum -CREATE TYPE "ProposalType" AS ENUM ('ADD_LORE', 'UPDATE_LORE', 'UPDATE_WORLD_BIBLE'); - --- CreateEnum -CREATE TYPE "ProposalStatus" AS ENUM ('DRAFT', 'SUBMITTED', 'VOTING', 'APPROVED_FOR_PUBLICATION', 'REJECTED', 'PUBLISHED', 'WITHDRAWN'); - --- CreateEnum -CREATE TYPE "VoteChoice" AS ENUM ('APPROVE', 'REJECT', 'ABSTAIN'); - --- CreateEnum -CREATE TYPE "AIReportStatus" AS ENUM ('PENDING', 'COMPLETED', 'FAILED'); - --- CreateEnum -CREATE TYPE "ModerationStatus" AS ENUM ('OPEN', 'IN_REVIEW', 'RESOLVED', 'DISMISSED'); - --- CreateEnum -CREATE TYPE "ModerationTargetType" AS ENUM ('USER', 'WORLD', 'WORLD_BIBLE_VERSION', 'LORE_ENTRY', 'LORE_RELATIONSHIP', 'PROPOSAL', 'APP_VOTE', 'AI_REPORT', 'HIVE_REFERENCE', 'HIVE_EVENT', 'REWARD_RECORD'); - --- CreateEnum -CREATE TYPE "HiveEntityType" AS ENUM ('WORLD_SEED', 'WORLD_BIBLE_VERSION', 'LORE_ENTRY', 'STORY_CHAPTER', 'CANON_DECISION', 'LORE_RELATIONSHIP', 'METADATA'); - --- CreateEnum -CREATE TYPE "HiveEventType" AS ENUM ('POST', 'COMMENT', 'CUSTOM_JSON', 'VOTE', 'REWARD', 'BENEFICIARY', 'OTHER'); - --- CreateEnum -CREATE TYPE "RewardType" AS ENUM ('AUTHOR', 'CURATION', 'BENEFICIARY', 'OTHER'); - --- CreateEnum -CREATE TYPE "SearchEntityType" AS ENUM ('WORLD', 'WORLD_BIBLE_VERSION', 'LORE_ENTRY', 'PROPOSAL'); - --- CreateTable -CREATE TABLE "User" ( - "id" TEXT NOT NULL, - "hiveUsername" TEXT NOT NULL, - "normalizedHiveUsername" TEXT NOT NULL, - "displayName" TEXT, - "avatarUrl" TEXT, - "bio" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "User_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "World" ( - "id" TEXT NOT NULL, - "slug" TEXT NOT NULL, - "title" TEXT NOT NULL, - "description" TEXT NOT NULL, - "founderId" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "World_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "WorldBibleVersion" ( - "id" TEXT NOT NULL, - "worldId" TEXT NOT NULL, - "versionNumber" INTEGER NOT NULL, - "creatorId" TEXT NOT NULL, - "content" JSONB NOT NULL, - "changeSummary" TEXT, - "publishedAt" TIMESTAMP(3), - "hiveReferenceId" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "WorldBibleVersion_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "LoreEntry" ( - "id" TEXT NOT NULL, - "worldId" TEXT NOT NULL, - "authorId" TEXT NOT NULL, - "title" TEXT NOT NULL, - "slug" TEXT NOT NULL, - "loreType" "LoreType" NOT NULL, - "content" JSONB NOT NULL, - "status" "LoreStatus" NOT NULL DEFAULT 'DRAFT', - "publishedAt" TIMESTAMP(3), - "hiveReferenceId" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "LoreEntry_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "LoreRelationship" ( - "id" TEXT NOT NULL, - "worldId" TEXT NOT NULL, - "sourceId" TEXT NOT NULL, - "targetId" TEXT NOT NULL, - "relationType" TEXT NOT NULL, - "metadata" JSONB, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "LoreRelationship_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Proposal" ( - "id" TEXT NOT NULL, - "worldId" TEXT NOT NULL, - "authorId" TEXT NOT NULL, - "proposalType" "ProposalType" NOT NULL, - "status" "ProposalStatus" NOT NULL DEFAULT 'DRAFT', - "title" TEXT NOT NULL, - "summary" TEXT NOT NULL, - "proposedContent" JSONB NOT NULL, - "targetLoreEntryId" TEXT, - "resultingLoreEntryId" TEXT, - "resultingBibleVersionId" TEXT, - "submittedAt" TIMESTAMP(3), - "votingStartedAt" TIMESTAMP(3), - "approvedAt" TIMESTAMP(3), - "rejectedAt" TIMESTAMP(3), - "publishedAt" TIMESTAMP(3), - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "Proposal_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "AppVote" ( - "id" TEXT NOT NULL, - "proposalId" TEXT NOT NULL, - "voterId" TEXT NOT NULL, - "choice" "VoteChoice" NOT NULL, - "reputationScoreAtVote" DECIMAL(20,4), - "reputationSnapshotId" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "AppVote_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "AIReport" ( - "id" TEXT NOT NULL, - "proposalId" TEXT NOT NULL, - "provider" TEXT NOT NULL, - "model" TEXT NOT NULL, - "status" "AIReportStatus" NOT NULL DEFAULT 'PENDING', - "summary" TEXT, - "findings" JSONB, - "errorCode" TEXT, - "errorMessage" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "AIReport_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "ModerationReport" ( - "id" TEXT NOT NULL, - "reporterId" TEXT NOT NULL, - "reviewerId" TEXT, - "worldId" TEXT, - "proposalId" TEXT, - "targetType" "ModerationTargetType" NOT NULL, - "targetId" TEXT NOT NULL, - "reason" TEXT NOT NULL, - "details" TEXT, - "status" "ModerationStatus" NOT NULL DEFAULT 'OPEN', - "reviewedAt" TIMESTAMP(3), - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "ModerationReport_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "HiveReference" ( - "id" TEXT NOT NULL, - "entityType" "HiveEntityType" NOT NULL, - "entityId" TEXT NOT NULL, - "hiveAuthorUsername" TEXT NOT NULL, - "authorId" TEXT, - "permlink" TEXT NOT NULL, - "transactionId" TEXT, - "blockNumber" BIGINT, - "operationIndex" INTEGER, - "publicationMetadata" JSONB, - "publishedAt" TIMESTAMP(3) NOT NULL, - "indexedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "HiveReference_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "HiveEvent" ( - "id" TEXT NOT NULL, - "blockNumber" BIGINT NOT NULL, - "transactionId" TEXT NOT NULL, - "operationIndex" INTEGER NOT NULL, - "eventType" "HiveEventType" NOT NULL, - "blockchainTimestamp" TIMESTAMP(3) NOT NULL, - "payload" JSONB NOT NULL, - "processedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "HiveEvent_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "RewardRecord" ( - "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "hiveReferenceId" TEXT, - "rewardType" "RewardType" NOT NULL, - "assetSymbol" TEXT NOT NULL, - "amount" DECIMAL(38,18) NOT NULL, - "transactionId" TEXT NOT NULL, - "operationIndex" INTEGER NOT NULL, - "blockNumber" BIGINT NOT NULL, - "payoutAt" TIMESTAMP(3) NOT NULL, - "idempotencyKey" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "RewardRecord_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "UserRewardSummary" ( - "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "assetSymbol" TEXT NOT NULL, - "totalAmount" DECIMAL(38,18) NOT NULL, - "lastCalculatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "UserRewardSummary_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "UserReputationSnapshot" ( - "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "score" DECIMAL(20,4) NOT NULL, - "calculationVersion" TEXT NOT NULL, - "breakdown" JSONB NOT NULL, - "calculatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "UserReputationSnapshot_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "SearchIndex" ( - "id" TEXT NOT NULL, - "worldId" TEXT, - "entityType" "SearchEntityType" NOT NULL, - "entityId" TEXT NOT NULL, - "title" TEXT NOT NULL, - "searchableContent" TEXT NOT NULL, - "metadata" JSONB, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "SearchIndex_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "User_hiveUsername_key" ON "User"("hiveUsername"); - --- CreateIndex -CREATE UNIQUE INDEX "User_normalizedHiveUsername_key" ON "User"("normalizedHiveUsername"); - --- CreateIndex -CREATE INDEX "User_normalizedHiveUsername_idx" ON "User"("normalizedHiveUsername"); - --- CreateIndex -CREATE UNIQUE INDEX "World_slug_key" ON "World"("slug"); - --- CreateIndex -CREATE INDEX "World_founderId_idx" ON "World"("founderId"); - --- CreateIndex -CREATE INDEX "World_createdAt_idx" ON "World"("createdAt"); - --- CreateIndex -CREATE UNIQUE INDEX "WorldBibleVersion_hiveReferenceId_key" ON "WorldBibleVersion"("hiveReferenceId"); - --- CreateIndex -CREATE INDEX "WorldBibleVersion_worldId_versionNumber_idx" ON "WorldBibleVersion"("worldId", "versionNumber"); - --- CreateIndex -CREATE INDEX "WorldBibleVersion_creatorId_idx" ON "WorldBibleVersion"("creatorId"); - --- CreateIndex -CREATE UNIQUE INDEX "WorldBibleVersion_worldId_versionNumber_key" ON "WorldBibleVersion"("worldId", "versionNumber"); - --- CreateIndex -CREATE UNIQUE INDEX "LoreEntry_hiveReferenceId_key" ON "LoreEntry"("hiveReferenceId"); - --- CreateIndex -CREATE INDEX "LoreEntry_worldId_status_idx" ON "LoreEntry"("worldId", "status"); - --- CreateIndex -CREATE INDEX "LoreEntry_worldId_loreType_idx" ON "LoreEntry"("worldId", "loreType"); - --- CreateIndex -CREATE INDEX "LoreEntry_authorId_idx" ON "LoreEntry"("authorId"); - --- CreateIndex -CREATE UNIQUE INDEX "LoreEntry_worldId_slug_key" ON "LoreEntry"("worldId", "slug"); - --- CreateIndex -CREATE INDEX "LoreRelationship_worldId_idx" ON "LoreRelationship"("worldId"); - --- CreateIndex -CREATE INDEX "LoreRelationship_sourceId_idx" ON "LoreRelationship"("sourceId"); - --- CreateIndex -CREATE INDEX "LoreRelationship_targetId_idx" ON "LoreRelationship"("targetId"); - --- CreateIndex -CREATE UNIQUE INDEX "LoreRelationship_worldId_sourceId_targetId_relationType_key" ON "LoreRelationship"("worldId", "sourceId", "targetId", "relationType"); - --- CreateIndex -CREATE UNIQUE INDEX "Proposal_resultingBibleVersionId_key" ON "Proposal"("resultingBibleVersionId"); - --- CreateIndex -CREATE INDEX "Proposal_worldId_status_idx" ON "Proposal"("worldId", "status"); - --- CreateIndex -CREATE INDEX "Proposal_authorId_idx" ON "Proposal"("authorId"); - --- CreateIndex -CREATE INDEX "Proposal_targetLoreEntryId_idx" ON "Proposal"("targetLoreEntryId"); - --- CreateIndex -CREATE INDEX "AppVote_proposalId_idx" ON "AppVote"("proposalId"); - --- CreateIndex -CREATE INDEX "AppVote_voterId_idx" ON "AppVote"("voterId"); - --- CreateIndex -CREATE UNIQUE INDEX "AppVote_proposalId_voterId_key" ON "AppVote"("proposalId", "voterId"); - --- CreateIndex -CREATE INDEX "AIReport_proposalId_idx" ON "AIReport"("proposalId"); - --- CreateIndex -CREATE INDEX "AIReport_status_idx" ON "AIReport"("status"); - --- CreateIndex -CREATE INDEX "ModerationReport_reporterId_idx" ON "ModerationReport"("reporterId"); - --- CreateIndex -CREATE INDEX "ModerationReport_reviewerId_idx" ON "ModerationReport"("reviewerId"); - --- CreateIndex -CREATE INDEX "ModerationReport_worldId_status_idx" ON "ModerationReport"("worldId", "status"); - --- CreateIndex -CREATE INDEX "ModerationReport_proposalId_idx" ON "ModerationReport"("proposalId"); - --- CreateIndex -CREATE INDEX "ModerationReport_targetType_targetId_idx" ON "ModerationReport"("targetType", "targetId"); - --- CreateIndex -CREATE INDEX "HiveReference_authorId_idx" ON "HiveReference"("authorId"); - --- CreateIndex -CREATE INDEX "HiveReference_blockNumber_idx" ON "HiveReference"("blockNumber"); - --- CreateIndex -CREATE INDEX "HiveReference_publishedAt_idx" ON "HiveReference"("publishedAt"); - --- CreateIndex -CREATE UNIQUE INDEX "HiveReference_entityType_entityId_key" ON "HiveReference"("entityType", "entityId"); - --- CreateIndex -CREATE UNIQUE INDEX "HiveReference_hiveAuthorUsername_permlink_key" ON "HiveReference"("hiveAuthorUsername", "permlink"); - --- CreateIndex -CREATE UNIQUE INDEX "HiveReference_transactionId_operationIndex_key" ON "HiveReference"("transactionId", "operationIndex"); - --- CreateIndex -CREATE INDEX "HiveEvent_blockNumber_operationIndex_idx" ON "HiveEvent"("blockNumber", "operationIndex"); - --- CreateIndex -CREATE INDEX "HiveEvent_eventType_idx" ON "HiveEvent"("eventType"); - --- CreateIndex -CREATE INDEX "HiveEvent_blockchainTimestamp_idx" ON "HiveEvent"("blockchainTimestamp"); - --- CreateIndex -CREATE UNIQUE INDEX "HiveEvent_transactionId_operationIndex_key" ON "HiveEvent"("transactionId", "operationIndex"); - --- CreateIndex -CREATE UNIQUE INDEX "RewardRecord_idempotencyKey_key" ON "RewardRecord"("idempotencyKey"); - --- CreateIndex -CREATE INDEX "RewardRecord_userId_payoutAt_idx" ON "RewardRecord"("userId", "payoutAt"); - --- CreateIndex -CREATE INDEX "RewardRecord_hiveReferenceId_idx" ON "RewardRecord"("hiveReferenceId"); - --- CreateIndex -CREATE INDEX "RewardRecord_blockNumber_idx" ON "RewardRecord"("blockNumber"); - --- CreateIndex -CREATE INDEX "RewardRecord_transactionId_operationIndex_idx" ON "RewardRecord"("transactionId", "operationIndex"); - --- CreateIndex -CREATE INDEX "UserRewardSummary_assetSymbol_idx" ON "UserRewardSummary"("assetSymbol"); - --- CreateIndex -CREATE UNIQUE INDEX "UserRewardSummary_userId_assetSymbol_key" ON "UserRewardSummary"("userId", "assetSymbol"); - --- CreateIndex -CREATE INDEX "UserReputationSnapshot_userId_calculatedAt_idx" ON "UserReputationSnapshot"("userId", "calculatedAt"); - --- CreateIndex -CREATE INDEX "UserReputationSnapshot_calculationVersion_idx" ON "UserReputationSnapshot"("calculationVersion"); - --- CreateIndex -CREATE INDEX "SearchIndex_worldId_entityType_idx" ON "SearchIndex"("worldId", "entityType"); - --- CreateIndex -CREATE UNIQUE INDEX "SearchIndex_entityType_entityId_key" ON "SearchIndex"("entityType", "entityId"); - --- AddForeignKey -ALTER TABLE "World" ADD CONSTRAINT "World_founderId_fkey" FOREIGN KEY ("founderId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "WorldBibleVersion" ADD CONSTRAINT "WorldBibleVersion_worldId_fkey" FOREIGN KEY ("worldId") REFERENCES "World"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "WorldBibleVersion" ADD CONSTRAINT "WorldBibleVersion_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "WorldBibleVersion" ADD CONSTRAINT "WorldBibleVersion_hiveReferenceId_fkey" FOREIGN KEY ("hiveReferenceId") REFERENCES "HiveReference"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "LoreEntry" ADD CONSTRAINT "LoreEntry_worldId_fkey" FOREIGN KEY ("worldId") REFERENCES "World"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "LoreEntry" ADD CONSTRAINT "LoreEntry_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "LoreEntry" ADD CONSTRAINT "LoreEntry_hiveReferenceId_fkey" FOREIGN KEY ("hiveReferenceId") REFERENCES "HiveReference"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "LoreRelationship" ADD CONSTRAINT "LoreRelationship_worldId_fkey" FOREIGN KEY ("worldId") REFERENCES "World"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "LoreRelationship" ADD CONSTRAINT "LoreRelationship_sourceId_fkey" FOREIGN KEY ("sourceId") REFERENCES "LoreEntry"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "LoreRelationship" ADD CONSTRAINT "LoreRelationship_targetId_fkey" FOREIGN KEY ("targetId") REFERENCES "LoreEntry"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Proposal" ADD CONSTRAINT "Proposal_worldId_fkey" FOREIGN KEY ("worldId") REFERENCES "World"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Proposal" ADD CONSTRAINT "Proposal_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Proposal" ADD CONSTRAINT "Proposal_targetLoreEntryId_fkey" FOREIGN KEY ("targetLoreEntryId") REFERENCES "LoreEntry"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Proposal" ADD CONSTRAINT "Proposal_resultingLoreEntryId_fkey" FOREIGN KEY ("resultingLoreEntryId") REFERENCES "LoreEntry"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Proposal" ADD CONSTRAINT "Proposal_resultingBibleVersionId_fkey" FOREIGN KEY ("resultingBibleVersionId") REFERENCES "WorldBibleVersion"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AppVote" ADD CONSTRAINT "AppVote_proposalId_fkey" FOREIGN KEY ("proposalId") REFERENCES "Proposal"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AppVote" ADD CONSTRAINT "AppVote_voterId_fkey" FOREIGN KEY ("voterId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AppVote" ADD CONSTRAINT "AppVote_reputationSnapshotId_fkey" FOREIGN KEY ("reputationSnapshotId") REFERENCES "UserReputationSnapshot"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AIReport" ADD CONSTRAINT "AIReport_proposalId_fkey" FOREIGN KEY ("proposalId") REFERENCES "Proposal"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ModerationReport" ADD CONSTRAINT "ModerationReport_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ModerationReport" ADD CONSTRAINT "ModerationReport_reviewerId_fkey" FOREIGN KEY ("reviewerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ModerationReport" ADD CONSTRAINT "ModerationReport_worldId_fkey" FOREIGN KEY ("worldId") REFERENCES "World"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ModerationReport" ADD CONSTRAINT "ModerationReport_proposalId_fkey" FOREIGN KEY ("proposalId") REFERENCES "Proposal"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "HiveReference" ADD CONSTRAINT "HiveReference_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "RewardRecord" ADD CONSTRAINT "RewardRecord_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "RewardRecord" ADD CONSTRAINT "RewardRecord_hiveReferenceId_fkey" FOREIGN KEY ("hiveReferenceId") REFERENCES "HiveReference"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "UserRewardSummary" ADD CONSTRAINT "UserRewardSummary_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "UserReputationSnapshot" ADD CONSTRAINT "UserReputationSnapshot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "SearchIndex" ADD CONSTRAINT "SearchIndex_worldId_fkey" FOREIGN KEY ("worldId") REFERENCES "World"("id") ON DELETE CASCADE ON UPDATE CASCADE; +-- This migration originally duplicated the full baseline created by +-- 20260718131500_init_hivelore_schema. Re-running those CREATE TYPE, table, +-- index, and foreign-key statements breaks fresh PostgreSQL deployments because +-- the authoritative baseline has already created the same schema objects. +-- +-- Keep this migration directory so databases that have recorded the historical +-- migration name do not see a deleted migration. Its body is intentionally a +-- no-op for fresh databases and for environments that only applied the earlier +-- baseline. diff --git a/apps/api/prisma/migrations/20260810120000_add_world_bible_audit_action/migration.sql b/apps/api/prisma/migrations/20260810120000_add_world_bible_audit_action/migration.sql new file mode 100644 index 0000000..6456e3f --- /dev/null +++ b/apps/api/prisma/migrations/20260810120000_add_world_bible_audit_action/migration.sql @@ -0,0 +1 @@ +ALTER TYPE "WorldAuditAction" ADD VALUE 'WORLD_BIBLE_UPDATED'; diff --git a/apps/api/prisma/migrations/20260810143000_add_canon_voting/migration.sql b/apps/api/prisma/migrations/20260810143000_add_canon_voting/migration.sql new file mode 100644 index 0000000..403fc11 --- /dev/null +++ b/apps/api/prisma/migrations/20260810143000_add_canon_voting/migration.sql @@ -0,0 +1,86 @@ +-- CreateEnum +CREATE TYPE "ProposalDecisionOutcome" AS ENUM ('APPROVED_FOR_PUBLICATION', 'REJECTED', 'NEEDS_REVISION', 'ALTERNATE_TIMELINE', 'STALE_BASE_CONFLICT'); + +-- AlterEnum +ALTER TYPE "ProposalStatus" ADD VALUE IF NOT EXISTS 'NEEDS_REVISION'; +ALTER TYPE "ProposalStatus" ADD VALUE IF NOT EXISTS 'ALTERNATE_TIMELINE'; + +-- AlterEnum +ALTER TYPE "VoteChoice" ADD VALUE IF NOT EXISTS 'NEEDS_REVISION'; +ALTER TYPE "VoteChoice" ADD VALUE IF NOT EXISTS 'ALTERNATE_TIMELINE'; + +-- AlterEnum +ALTER TYPE "WorldAuditAction" ADD VALUE IF NOT EXISTS 'CANON_DECISION_FINALIZED'; + +-- AlterTable +ALTER TABLE "Proposal" + ADD COLUMN "votingEndsAt" TIMESTAMP(3), + ADD COLUMN "decidedAt" TIMESTAMP(3), + ADD COLUMN "contentHash" TEXT, + ADD COLUMN "aiWarningAcknowledgedAt" TIMESTAMP(3), + ADD COLUMN "baseCanonVersionId" TEXT, + ADD COLUMN "branchParentProposalId" TEXT, + ADD COLUMN "branchBaseLoreEntryId" TEXT, + ADD COLUMN "branchLabel" TEXT, + ADD COLUMN "conflictMetadata" JSONB, + ADD COLUMN "staleBaseAtDecision" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +CREATE TABLE "ProposalDecision" ( + "id" TEXT NOT NULL, + "proposalId" TEXT NOT NULL, + "outcome" "ProposalDecisionOutcome" NOT NULL, + "decidedAt" TIMESTAMP(3) NOT NULL, + "rulesVersion" TEXT NOT NULL, + "payloadSchemaVersion" INTEGER NOT NULL, + "minimumVotes" INTEGER NOT NULL, + "approvalThresholdBps" INTEGER NOT NULL, + "votingWindowHours" INTEGER NOT NULL, + "approveCount" INTEGER NOT NULL, + "rejectCount" INTEGER NOT NULL, + "needsRevisionCount" INTEGER NOT NULL, + "alternateTimelineCount" INTEGER NOT NULL, + "totalVotes" INTEGER NOT NULL, + "approvalNumerator" INTEGER NOT NULL, + "approvalDenominator" INTEGER NOT NULL, + "approvalPercentageBps" INTEGER NOT NULL, + "aiWarningAcknowledged" BOOLEAN NOT NULL DEFAULT false, + "aiWarningSummary" TEXT, + "baseCanonVersionId" TEXT, + "branchParentProposalId" TEXT, + "branchBaseLoreEntryId" TEXT, + "branchLabel" TEXT, + "conflictMetadata" JSONB, + "staleBaseAtDecision" BOOLEAN NOT NULL DEFAULT false, + "contentHash" TEXT NOT NULL, + "decisionPayload" JSONB NOT NULL, + "decisionPayloadHash" TEXT NOT NULL, + "customJsonId" TEXT, + "expectedSigner" TEXT, + "hiveEventId" TEXT, + "transactionId" TEXT, + "blockNumber" BIGINT, + "operationIndex" INTEGER, + "blockchainTimestamp" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "ProposalDecision_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProposalDecision_proposalId_key" ON "ProposalDecision"("proposalId"); +CREATE UNIQUE INDEX "ProposalDecision_decisionPayloadHash_key" ON "ProposalDecision"("decisionPayloadHash"); +CREATE UNIQUE INDEX "ProposalDecision_hiveEventId_key" ON "ProposalDecision"("hiveEventId"); +CREATE UNIQUE INDEX "ProposalDecision_transactionId_operationIndex_key" ON "ProposalDecision"("transactionId", "operationIndex"); +CREATE INDEX "ProposalDecision_outcome_idx" ON "ProposalDecision"("outcome"); +CREATE INDEX "ProposalDecision_decidedAt_idx" ON "ProposalDecision"("decidedAt"); +CREATE INDEX "ProposalDecision_customJsonId_idx" ON "ProposalDecision"("customJsonId"); +CREATE INDEX "ProposalDecision_baseCanonVersionId_idx" ON "ProposalDecision"("baseCanonVersionId"); +CREATE INDEX "ProposalDecision_branchBaseLoreEntryId_idx" ON "ProposalDecision"("branchBaseLoreEntryId"); +CREATE INDEX "Proposal_votingEndsAt_idx" ON "Proposal"("votingEndsAt"); +CREATE INDEX "Proposal_baseCanonVersionId_idx" ON "Proposal"("baseCanonVersionId"); +CREATE INDEX "Proposal_branchBaseLoreEntryId_idx" ON "Proposal"("branchBaseLoreEntryId"); + +-- AddForeignKey +ALTER TABLE "ProposalDecision" ADD CONSTRAINT "ProposalDecision_proposalId_fkey" FOREIGN KEY ("proposalId") REFERENCES "Proposal"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "ProposalDecision" ADD CONSTRAINT "ProposalDecision_hiveEventId_fkey" FOREIGN KEY ("hiveEventId") REFERENCES "HiveEvent"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260810170000_add_proposal_comments/migration.sql b/apps/api/prisma/migrations/20260810170000_add_proposal_comments/migration.sql new file mode 100644 index 0000000..7046d6a --- /dev/null +++ b/apps/api/prisma/migrations/20260810170000_add_proposal_comments/migration.sql @@ -0,0 +1,23 @@ +-- Proposal discussion is mutable, off-chain application state. It never determines canon. +ALTER TYPE "ModerationTargetType" ADD VALUE 'PROPOSAL_COMMENT'; + +CREATE TABLE "ProposalComment" ( + "id" TEXT NOT NULL, + "proposalId" TEXT NOT NULL, + "authorId" TEXT NOT NULL, + "body" VARCHAR(3000) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deletedAt" TIMESTAMP(3), + "deletedById" TEXT, + + CONSTRAINT "ProposalComment_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "ProposalComment_proposalId_createdAt_id_idx" ON "ProposalComment"("proposalId", "createdAt", "id"); +CREATE INDEX "ProposalComment_authorId_createdAt_idx" ON "ProposalComment"("authorId", "createdAt"); +CREATE INDEX "ProposalComment_deletedById_idx" ON "ProposalComment"("deletedById"); + +ALTER TABLE "ProposalComment" ADD CONSTRAINT "ProposalComment_proposalId_fkey" FOREIGN KEY ("proposalId") REFERENCES "Proposal"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "ProposalComment" ADD CONSTRAINT "ProposalComment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "ProposalComment" ADD CONSTRAINT "ProposalComment_deletedById_fkey" FOREIGN KEY ("deletedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "ProposalComment" ADD CONSTRAINT "ProposalComment_body_not_empty" CHECK (length(btrim("body")) > 0); diff --git a/apps/api/prisma/migrations/20260811130000_align_indexer_watermark_index/migration.sql b/apps/api/prisma/migrations/20260811130000_align_indexer_watermark_index/migration.sql new file mode 100644 index 0000000..8b7a019 --- /dev/null +++ b/apps/api/prisma/migrations/20260811130000_align_indexer_watermark_index/migration.sql @@ -0,0 +1,6 @@ +-- PostgreSQL truncates identifiers to 63 bytes. The original migration requested +-- the longer Prisma-generated name, which is stored as the shorter mapped name below. +DROP INDEX IF EXISTS "IndexerWatermark_lastProcessedBlock_lastProcessedOperationIndex_idx"; + +CREATE INDEX "IndexerWatermark_lastProcessedBlock_lastProcessedOperationI_idx" +ON "IndexerWatermark"("lastProcessedBlock", "lastProcessedOperationIndex"); diff --git a/apps/api/prisma/migrations/20260812120000_add_canon_confirmation_audit_action/migration.sql b/apps/api/prisma/migrations/20260812120000_add_canon_confirmation_audit_action/migration.sql new file mode 100644 index 0000000..7b81653 --- /dev/null +++ b/apps/api/prisma/migrations/20260812120000_add_canon_confirmation_audit_action/migration.sql @@ -0,0 +1 @@ +ALTER TYPE "WorldAuditAction" ADD VALUE IF NOT EXISTS 'CANON_DECISION_CONFIRMED'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 040ef6f..af045b8 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -52,6 +52,8 @@ enum ProposalStatus { VOTING APPROVED_FOR_PUBLICATION REJECTED + NEEDS_REVISION + ALTERNATE_TIMELINE PUBLISHED WITHDRAWN } @@ -59,9 +61,19 @@ enum ProposalStatus { enum VoteChoice { APPROVE REJECT + NEEDS_REVISION + ALTERNATE_TIMELINE ABSTAIN } +enum ProposalDecisionOutcome { + APPROVED_FOR_PUBLICATION + REJECTED + NEEDS_REVISION + ALTERNATE_TIMELINE + STALE_BASE_CONFLICT +} + enum AIReportStatus { PENDING COMPLETED @@ -82,6 +94,7 @@ enum ModerationTargetType { LORE_ENTRY LORE_RELATIONSHIP PROPOSAL + PROPOSAL_COMMENT APP_VOTE AI_REPORT HIVE_REFERENCE @@ -147,6 +160,9 @@ enum WorldAuditAction { CONTRIBUTION_DELETED CONTRIBUTION_SUBMITTED PROPOSAL_CREATED + CANON_DECISION_FINALIZED + CANON_DECISION_CONFIRMED + WORLD_BIBLE_UPDATED LORE_ENTRY_CREATED LORE_ENTRY_UPDATED LORE_ENTRY_DELETED @@ -191,6 +207,8 @@ model User { bibleVersions WorldBibleVersion[] @relation("WorldBibleVersionCreator") loreEntries LoreEntry[] @relation("LoreEntryAuthor") proposals Proposal[] @relation("ProposalAuthor") + proposalComments ProposalComment[] @relation("ProposalCommentAuthor") + deletedProposalComments ProposalComment[] @relation("ProposalCommentDeleter") contributionDrafts ContributionDraft[] @relation("ContributionDraftAuthor") votes AppVote[] @relation("AppVoteVoter") moderationReports ModerationReport[] @relation("ModerationReporter") @@ -497,20 +515,53 @@ model Proposal { resultingBibleVersion WorldBibleVersion? @relation("ProposalResultingBibleVersion", fields: [resultingBibleVersionId], references: [id], onDelete: SetNull) submittedAt DateTime? votingStartedAt DateTime? + votingEndsAt DateTime? + decidedAt DateTime? approvedAt DateTime? rejectedAt DateTime? publishedAt DateTime? + contentHash String? + aiWarningAcknowledgedAt DateTime? + baseCanonVersionId String? + branchParentProposalId String? + branchBaseLoreEntryId String? + branchLabel String? + conflictMetadata Json? + staleBaseAtDecision Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt contributionDraft ContributionDraft? votes AppVote[] + decision ProposalDecision? aiReports AIReport[] moderationReports ModerationReport[] + comments ProposalComment[] @@index([worldId, status]) @@index([authorId]) @@index([worldId, contributionKind]) @@index([targetLoreEntryId]) + @@index([votingEndsAt]) + @@index([baseCanonVersionId]) + @@index([branchBaseLoreEntryId]) +} + +model ProposalComment { + id String @id @default(cuid()) + proposalId String + proposal Proposal @relation(fields: [proposalId], references: [id], onDelete: Restrict) + authorId String + author User @relation("ProposalCommentAuthor", fields: [authorId], references: [id], onDelete: Restrict) + body String @db.VarChar(3000) + createdAt DateTime @default(now()) + deletedAt DateTime? + deletedById String? + deletedBy User? @relation("ProposalCommentDeleter", fields: [deletedById], references: [id], onDelete: SetNull) + /// Off-chain plain-text discussion. Comments never count as AppVote rows or canon decisions. + + @@index([proposalId, createdAt, id]) + @@index([authorId, createdAt]) + @@index([deletedById]) } model ContributionDraft { @@ -558,6 +609,55 @@ model AppVote { @@index([voterId]) } +model ProposalDecision { + id String @id @default(cuid()) + proposalId String @unique + proposal Proposal @relation(fields: [proposalId], references: [id], onDelete: Restrict) + outcome ProposalDecisionOutcome + decidedAt DateTime + rulesVersion String + payloadSchemaVersion Int + minimumVotes Int + approvalThresholdBps Int + votingWindowHours Int + approveCount Int + rejectCount Int + needsRevisionCount Int + alternateTimelineCount Int + totalVotes Int + approvalNumerator Int + approvalDenominator Int + approvalPercentageBps Int + aiWarningAcknowledged Boolean @default(false) + aiWarningSummary String? + baseCanonVersionId String? + branchParentProposalId String? + branchBaseLoreEntryId String? + branchLabel String? + conflictMetadata Json? + staleBaseAtDecision Boolean @default(false) + contentHash String + decisionPayload Json + decisionPayloadHash String @unique + customJsonId String? + expectedSigner String? + hiveEventId String? @unique + hiveEvent HiveEvent? @relation(fields: [hiveEventId], references: [id], onDelete: SetNull) + transactionId String? + blockNumber BigInt? + operationIndex Int? + blockchainTimestamp DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([transactionId, operationIndex]) + @@index([outcome]) + @@index([decidedAt]) + @@index([customJsonId]) + @@index([baseCanonVersionId]) + @@index([branchBaseLoreEntryId]) +} + model AIReport { id String @id @default(cuid()) proposalId String @@ -634,16 +734,17 @@ model HiveReference { } model HiveEvent { - id String @id @default(cuid()) + id String @id @default(cuid()) blockNumber BigInt transactionId String operationIndex Int eventType HiveEventType blockchainTimestamp DateTime payload Json - processedAt DateTime @default(now()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + processedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + proposalDecision ProposalDecision? /// Rebuildable indexed Hive operation; the blockchain remains authoritative. @@unique([transactionId, operationIndex]) @@ -662,7 +763,7 @@ model IndexerWatermark { updatedAt DateTime @updatedAt /// Durable resume point for rebuildable indexer processes. - @@index([lastProcessedBlock, lastProcessedOperationIndex]) + @@index([lastProcessedBlock, lastProcessedOperationIndex], map: "IndexerWatermark_lastProcessedBlock_lastProcessedOperationI_idx") } model RewardRecord { diff --git a/apps/api/scripts/check-fresh-prisma-migrations.mjs b/apps/api/scripts/check-fresh-prisma-migrations.mjs new file mode 100644 index 0000000..eb9e548 --- /dev/null +++ b/apps/api/scripts/check-fresh-prisma-migrations.mjs @@ -0,0 +1,225 @@ +import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import process from 'node:process'; + +import pg from 'pg'; + +const { Client } = pg; + +const disposablePrefix = 'hivelore_migrate_check_'; +const adminUrl = process.env.TEST_DATABASE_ADMIN_URL; + +if (!adminUrl) { + throw new Error( + 'TEST_DATABASE_ADMIN_URL is required, for example postgresql://postgres:postgres@127.0.0.1:5432/postgres', + ); +} + +const databaseName = `${disposablePrefix}${Date.now()}_${process.pid}_${randomUUID().replaceAll('-', '').slice(0, 8)}`; + +if (!databaseName.startsWith(disposablePrefix)) { + throw new Error(`Refusing to use non-disposable database name: ${databaseName}`); +} + +const databaseUrl = new URL(adminUrl); +databaseUrl.pathname = `/${databaseName}`; +const directUrl = databaseUrl.toString(); + +function runPrisma(args, options = {}) { + const executable = process.platform === 'win32' ? 'npx.cmd' : 'npx'; + const result = spawnSync(executable, ['prisma', ...args], { + cwd: new URL('..', import.meta.url), + env: { + ...process.env, + DATABASE_URL: directUrl, + DIRECT_URL: directUrl, + NODE_ENV: 'test', + }, + shell: false, + stdio: 'inherit', + ...options, + }); + + if (result.status !== 0) { + throw new Error(`prisma ${args.join(' ')} failed with exit code ${result.status}`); + } +} + +async function withAdminClient(callback) { + const client = new Client({ connectionString: adminUrl }); + await client.connect(); + + try { + return await callback(client); + } finally { + await client.end(); + } +} + +async function createDatabase() { + await withAdminClient((client) => client.query(`CREATE DATABASE "${databaseName}"`)); +} + +async function dropDatabase() { + if (!databaseName.startsWith(disposablePrefix)) { + throw new Error(`Refusing to drop non-disposable database: ${databaseName}`); + } + + await withAdminClient(async (client) => { + await client.query( + ` + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 + AND pid <> pg_backend_pid() + `, + [databaseName], + ); + await client.query(`DROP DATABASE IF EXISTS "${databaseName}"`); + }); +} + +async function smokeCheckSchema() { + const client = new Client({ connectionString: directUrl }); + await client.connect(); + + try { + const tables = await client.query( + ` + SELECT tablename + FROM pg_tables + WHERE schemaname = 'public' + AND tablename = ANY($1) + `, + [ + [ + 'User', + 'World', + 'WorldSeed', + 'WorldMembership', + 'LoreEntry', + 'Proposal', + 'ProposalDecision', + 'ProposalComment', + 'IndexerWatermark', + ], + ], + ); + + const enums = await client.query( + ` + SELECT typname + FROM pg_type + WHERE typname = ANY($1) + `, + [ + [ + 'LoreType', + 'WorldAuditAction', + 'ProposalDecisionOutcome', + 'ContributionKind', + 'AuthProvider', + ], + ], + ); + + const constraints = await client.query( + ` + SELECT conname + FROM pg_constraint + WHERE conname = ANY($1) + `, + [ + [ + 'World_founderId_fkey', + 'ProposalDecision_proposalId_fkey', + 'ProposalComment_body_not_empty', + ], + ], + ); + + const indexes = await client.query( + ` + SELECT indexname + FROM pg_indexes + WHERE schemaname = 'public' + AND indexname = ANY($1) + `, + [ + [ + 'LoreEntry_worldId_slug_key', + 'ProposalDecision_decisionPayloadHash_key', + 'ProposalComment_proposalId_createdAt_id_idx', + ], + ], + ); + + const seedCounts = await client.query(` + SELECT + (SELECT COUNT(*)::int FROM "User") AS users, + (SELECT COUNT(*)::int FROM "World") AS worlds, + (SELECT COUNT(*)::int FROM "WorldSeed") AS world_seeds, + (SELECT COUNT(*)::int FROM "ContributionDraft") AS contribution_drafts + `); + + const expectations = [ + ['tables', tables.rowCount, 9], + ['enums', enums.rowCount, 5], + ['constraints', constraints.rowCount, 3], + ['indexes', indexes.rowCount, 3], + ]; + + for (const [label, actual, expected] of expectations) { + if (actual !== expected) { + throw new Error(`Expected ${expected} ${label}, found ${actual}`); + } + } + + const counts = seedCounts.rows[0]; + if ( + counts.users < 2 || + counts.worlds < 1 || + counts.world_seeds < 1 || + counts.contribution_drafts < 1 + ) { + throw new Error(`Seed smoke check failed: ${JSON.stringify(counts)}`); + } + } finally { + await client.end(); + } +} + +try { + console.log(`Creating disposable PostgreSQL database ${databaseName}`); + await createDatabase(); + + console.log('Running prisma migrate deploy from migration zero'); + runPrisma(['migrate', 'deploy']); + + console.log('Re-running prisma migrate deploy to prove idempotency'); + runPrisma(['migrate', 'deploy']); + + console.log('Checking deployed schema for drift against schema.prisma'); + runPrisma([ + 'migrate', + 'diff', + '--from-config-datasource', + '--to-schema', + 'prisma/schema.prisma', + '--exit-code', + ]); + + console.log('Generating Prisma Client'); + runPrisma(['generate']); + + console.log('Running development seed against disposable database'); + runPrisma(['db', 'seed']); + + console.log('Smoke-checking tables, enums, constraints, indexes, and seed data'); + await smokeCheckSchema(); + + console.log('Fresh Prisma migration check passed'); +} finally { + console.log(`Dropping disposable PostgreSQL database ${databaseName}`); + await dropDatabase(); +} diff --git a/apps/api/scripts/check-upgrade-prisma-migrations.mjs b/apps/api/scripts/check-upgrade-prisma-migrations.mjs new file mode 100644 index 0000000..f3da2d5 --- /dev/null +++ b/apps/api/scripts/check-upgrade-prisma-migrations.mjs @@ -0,0 +1,177 @@ +import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import pg from 'pg'; + +const { Client } = pg; + +const disposablePrefix = 'hivelore_migrate_upgrade_'; +const adminUrl = process.env.TEST_DATABASE_ADMIN_URL; +const baseSchema = process.env.TEST_DATABASE_BASE_PRISMA_SCHEMA; +const repairedMigration = '20260718154842_init'; +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const apiWorkspaceDir = path.resolve(scriptDir, '..'); +const repoRoot = path.resolve(apiWorkspaceDir, '..', '..'); +const prismaExecutable = path.join( + repoRoot, + 'node_modules', + '.bin', + process.platform === 'win32' ? 'prisma.cmd' : 'prisma', +); + +if (!adminUrl) { + throw new Error( + 'TEST_DATABASE_ADMIN_URL is required, for example postgresql://postgres:postgres@127.0.0.1:5432/postgres', + ); +} + +if (!baseSchema) { + throw new Error('TEST_DATABASE_BASE_PRISMA_SCHEMA must point to the base branch schema.prisma.'); +} + +const databaseName = `${disposablePrefix}${Date.now()}_${process.pid}_${randomUUID().replaceAll('-', '').slice(0, 8)}`; + +if (!databaseName.startsWith(disposablePrefix)) { + throw new Error(`Refusing to use non-disposable database name: ${databaseName}`); +} + +const databaseUrl = new URL(adminUrl); +databaseUrl.pathname = `/${databaseName}`; +const directUrl = databaseUrl.toString(); + +function runPrisma(args, options = {}) { + const result = spawnSync(prismaExecutable, args, { + cwd: options.cwd ?? apiWorkspaceDir, + env: { + ...process.env, + DATABASE_URL: directUrl, + DIRECT_URL: directUrl, + NODE_ENV: 'test', + }, + shell: false, + stdio: 'inherit', + ...options, + }); + + if (!options.allowFailure && result.status !== 0) { + throw new Error(`prisma ${args.join(' ')} failed with exit code ${result.status}`); + } + + return result.status ?? 1; +} + +function getBaseApiWorkspaceDir(schemaPath) { + return path.dirname(path.dirname(path.resolve(schemaPath))); +} + +async function createBasePrismaConfig(apiWorkspaceDir) { + const configPath = path.join(apiWorkspaceDir, 'prisma.config.ts'); + + await writeFile( + configPath, + ` +export default { + schema: 'prisma/schema.prisma', + migrations: { + path: 'prisma/migrations', + }, + datasource: { + url: process.env.DIRECT_URL, + }, +}; +`.trimStart(), + ); + + return configPath; +} + +async function withAdminClient(callback) { + const client = new Client({ connectionString: adminUrl }); + await client.connect(); + + try { + return await callback(client); + } finally { + await client.end(); + } +} + +async function createDatabase() { + await withAdminClient((client) => client.query(`CREATE DATABASE "${databaseName}"`)); +} + +async function dropDatabase() { + if (!databaseName.startsWith(disposablePrefix)) { + throw new Error(`Refusing to drop non-disposable database: ${databaseName}`); + } + + await withAdminClient(async (client) => { + await client.query( + ` + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 + AND pid <> pg_backend_pid() + `, + [databaseName], + ); + await client.query(`DROP DATABASE IF EXISTS "${databaseName}"`); + }); +} + +try { + console.log(`Creating disposable PostgreSQL database ${databaseName}`); + await createDatabase(); + + console.log('Deploying base branch migration history'); + const baseApiWorkspaceDir = getBaseApiWorkspaceDir(baseSchema); + const baseConfig = await createBasePrismaConfig(baseApiWorkspaceDir); + const baseStatus = runPrisma( + ['migrate', 'deploy', '--schema', baseSchema, '--config', baseConfig], + { + allowFailure: true, + cwd: baseApiWorkspaceDir, + }, + ); + + if (baseStatus !== 0) { + console.log(`Resolving historical failed migration ${repairedMigration} as rolled back`); + runPrisma( + [ + 'migrate', + 'resolve', + '--rolled-back', + repairedMigration, + '--schema', + baseSchema, + '--config', + baseConfig, + ], + { + cwd: baseApiWorkspaceDir, + }, + ); + } + + console.log('Deploying repaired migration history from this branch'); + runPrisma(['migrate', 'deploy']); + + console.log('Checking upgraded schema for drift against schema.prisma'); + runPrisma([ + 'migrate', + 'diff', + '--from-config-datasource', + '--to-schema', + 'prisma/schema.prisma', + '--exit-code', + ]); + + console.log('Prisma migration upgrade check passed'); +} finally { + console.log(`Dropping disposable PostgreSQL database ${databaseName}`); + await dropDatabase(); +} diff --git a/apps/api/scripts/run-tests.mjs b/apps/api/scripts/run-tests.mjs new file mode 100644 index 0000000..d15f3f9 --- /dev/null +++ b/apps/api/scripts/run-tests.mjs @@ -0,0 +1,60 @@ +import { spawnSync } from 'node:child_process'; +import { readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import process from 'node:process'; + +function findTestFiles(directory) { + return readdirSync(directory).flatMap((entry) => { + const path = join(directory, entry); + const stat = statSync(path); + + if (stat.isDirectory()) { + return findTestFiles(path); + } + + return entry.endsWith('.test.ts') ? [path] : []; + }); +} + +const testFiles = findTestFiles('src'); + +if (testFiles.length === 0) { + throw new Error('No API test files were discovered.'); +} + +console.log(`Discovered ${testFiles.length} API test files.`); + +const testArgs = ['tsx', '--test', ...testFiles]; +const command = + process.platform === 'win32' + ? { + args: testArgs, + executable: 'npx.cmd', + shell: true, + } + : { + args: testArgs, + executable: 'npx', + shell: false, + }; +const result = spawnSync(command.executable, command.args, { + env: { + ...process.env, + AUTH_JWT_SECRET: process.env.AUTH_JWT_SECRET ?? 'test-only-jwt-secret-with-enough-entropy', + AUTH_REFRESH_SECRET: + process.env.AUTH_REFRESH_SECRET ?? 'test-only-refresh-secret-with-enough-entropy', + NODE_ENV: 'test', + }, + shell: command.shell, + stdio: 'inherit', +}); + +if (result.error) { + console.error(result.error); +} + +if (result.status !== 0) { + console.error(`API test command exited with status ${result.status ?? 'null'}.`); +} + +process.exit(result.status ?? 1); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index e3e2fea..7c1bc65 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -9,6 +9,7 @@ import { registerAuthRoutes } from './routes/auth.js'; import { registerContributionRoutes } from './routes/contributions.js'; import { registerHealthRoute } from './routes/health.js'; import { registerLoreRoutes } from './routes/lore.js'; +import { registerProposalRoutes } from './routes/proposals.js'; import { registerWorldRoutes } from './routes/worlds.js'; export async function buildApp() { @@ -39,6 +40,9 @@ export async function buildApp() { await app.register(rateLimit, { global: false, skipOnError: false, + // Route scope already isolates /auth/refresh from /auth/challenge inside this shared + // namespace. A separate namespace is only warranted for independent cleanup, retention, + // or monitoring. store: createPrismaRateLimitStore(prisma), }); @@ -47,6 +51,7 @@ export async function buildApp() { await registerWorldRoutes(app); await registerLoreRoutes(app); await registerContributionRoutes(app); + await registerProposalRoutes(app); return app; } diff --git a/apps/api/src/config/env.test.ts b/apps/api/src/config/env.test.ts index 601e6cc..2353997 100644 --- a/apps/api/src/config/env.test.ts +++ b/apps/api/src/config/env.test.ts @@ -64,4 +64,48 @@ describe('API environment validation', () => { assert.match(env.AUTH_JWT_SECRET, /test-only/); assert.match(env.AUTH_REFRESH_SECRET, /test-only/); }); + + test('parses mainnet Hive config and bounded broadcast defaults', () => { + const env = parseEnv({ + NODE_ENV: 'test', + }); + + assert.equal( + env.HIVE_MAINNET_CHAIN_ID, + 'beeab0de00000000000000000000000000000000000000000000000000000000', + ); + assert.equal(env.HIVE_BROADCAST_MAX_ATTEMPTS, 4); + assert.equal(env.HIVE_BROADCAST_TOTAL_DEADLINE_MS, 90_000); + assert.equal(env.HIVE_CONFIRMATION_TIMEOUT_MS, 60_000); + }); + + test('parses bounded proposal comment write rate-limit defaults', () => { + const env = parseEnv({ + NODE_ENV: 'test', + }); + + assert.equal(env.PROPOSAL_COMMENT_WRITE_RATE_LIMIT_CACHE, 10_000); + assert.equal(env.PROPOSAL_COMMENT_WRITE_RATE_LIMIT_MAX, 5); + assert.equal(env.PROPOSAL_COMMENT_WRITE_RATE_LIMIT_WINDOW_SECONDS, 60); + }); + + test('rejects invalid Hive network configuration', () => { + assert.throws( + () => + parseEnv({ + HIVE_MAINNET_CHAIN_ID: 'not-a-chain-id', + NODE_ENV: 'test', + }), + /HIVE_MAINNET_CHAIN_ID/, + ); + + assert.throws( + () => + parseEnv({ + NODE_ENV: 'test', + PROPOSAL_COMMENT_WRITE_RATE_LIMIT_MAX: '0', + }), + /PROPOSAL_COMMENT_WRITE_RATE_LIMIT_MAX/, + ); + }); }); diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index d5390b2..8939fa1 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -1,6 +1,8 @@ import { config as loadDotenv } from 'dotenv'; import { z } from 'zod'; +import { DEFAULT_HIVE_RETRY_CONFIG } from '../lib/hive/network-config.js'; + loadDotenv(); const booleanEnv = (defaultValue: boolean) => @@ -32,7 +34,70 @@ const envSchema = z.object({ AUTH_CHALLENGE_TTL_SECONDS: z.coerce.number().int().positive().default(300), AUTH_COOKIE_DOMAIN: z.string().optional(), AUTH_COOKIE_SECURE: optionalBooleanEnv, + PROPOSAL_COMMENT_WRITE_RATE_LIMIT_CACHE: z.coerce.number().int().positive().default(10_000), + PROPOSAL_COMMENT_WRITE_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(5), + PROPOSAL_COMMENT_WRITE_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().positive().default(60), HIVE_RPC_URL: z.string().url().default('https://api.hive.blog'), + HIVE_MAINNET_CHAIN_ID: z + .string() + .regex(/^[a-f0-9]{64}$/i) + .default('beeab0de00000000000000000000000000000000000000000000000000000000'), + HIVE_MAINNET_RPC_NODES: z.string().min(1).default('https://api.hive.blog'), + HIVE_MAINNET_HAF_URL: z.string().url().default('https://api.hive.blog/hafbe-api'), + HIVE_BROADCAST_MAX_ATTEMPTS: z.coerce + .number() + .int() + .positive() + .default(DEFAULT_HIVE_RETRY_CONFIG.maxAttempts), + HIVE_BROADCAST_INITIAL_DELAY_MS: z.coerce + .number() + .int() + .positive() + .default(DEFAULT_HIVE_RETRY_CONFIG.initialDelayMs), + HIVE_BROADCAST_MAX_DELAY_MS: z.coerce + .number() + .int() + .positive() + .default(DEFAULT_HIVE_RETRY_CONFIG.maxDelayMs), + HIVE_BROADCAST_BACKOFF_MULTIPLIER: z.coerce + .number() + .positive() + .default(DEFAULT_HIVE_RETRY_CONFIG.backoffMultiplier), + HIVE_BROADCAST_JITTER_RATIO: z.coerce + .number() + .min(0) + .max(1) + .default(DEFAULT_HIVE_RETRY_CONFIG.jitterRatio), + HIVE_BROADCAST_TIMEOUT_MS: z.coerce + .number() + .int() + .positive() + .default(DEFAULT_HIVE_RETRY_CONFIG.requestTimeoutMs), + HIVE_BROADCAST_TOTAL_DEADLINE_MS: z.coerce + .number() + .int() + .positive() + .default(DEFAULT_HIVE_RETRY_CONFIG.totalDeadlineMs), + HIVE_CONFIRMATION_POLL_INTERVAL_MS: z.coerce + .number() + .int() + .positive() + .default(DEFAULT_HIVE_RETRY_CONFIG.confirmationPollIntervalMs), + HIVE_CONFIRMATION_TIMEOUT_MS: z.coerce + .number() + .int() + .positive() + .default(DEFAULT_HIVE_RETRY_CONFIG.confirmationTimeoutMs), + HIVE_NODE_MAX_CONSECUTIVE_FAILURES: z.coerce + .number() + .int() + .positive() + .default(DEFAULT_HIVE_RETRY_CONFIG.maxConsecutiveNodeFailures), + HIVE_NODE_COOLDOWN_MS: z.coerce + .number() + .int() + .positive() + .default(DEFAULT_HIVE_RETRY_CONFIG.nodeCooldownMs), HIVE_AUTH_AUDIENCE: z.string().min(1).default('hivelore-local-api'), HAF_API_URL: z.string().url().default('https://api.hive.blog/hafbe-api'), HIVELORE_APP_ID: z.string().min(1).default('hivelore/0.1.0'), diff --git a/apps/api/src/lib/auth-rate-limit-store.test.ts b/apps/api/src/lib/auth-rate-limit-store.test.ts index 1fd9295..681bd1d 100644 --- a/apps/api/src/lib/auth-rate-limit-store.test.ts +++ b/apps/api/src/lib/auth-rate-limit-store.test.ts @@ -1,7 +1,12 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import { createPrismaRateLimitStore } from './auth-rate-limit-store.js'; +import rateLimit from '@fastify/rate-limit'; +import Fastify from 'fastify'; + +import { AuthProvider } from '../generated/prisma/enums.js'; +import { registerAuthRoutes } from '../routes/auth.js'; +import { createPrismaRateLimitStore, extractRateLimitRouteInfo } from './auth-rate-limit-store.js'; function createDatabase() { const buckets = new Map(); @@ -121,4 +126,97 @@ describe('Prisma auth rate-limit store', () => { assert.equal((await increment(verifyStore)).current, 1); assert.equal(database.buckets.size, 2); }); + + test('extracts nested Fastify routeInfo from child options', () => { + assert.deepEqual( + extractRateLimitRouteInfo({ + max: 40, + routeInfo: { + method: 'POST', + path: '/auth/refresh', + url: '/auth/refresh', + }, + } as never), + { + method: 'POST', + path: '/auth/refresh', + url: '/auth/refresh', + }, + ); + }); + + test('falls back safely when child options contain no route information', () => { + const routeInfo = extractRateLimitRouteInfo({} as never); + + assert.equal(routeInfo.method, undefined); + assert.equal(routeInfo.path, undefined); + assert.equal(routeInfo.url, undefined); + }); + + test('exhausting refresh does not rate-limit challenge', async () => { + const database = createDatabase(); + const Store = createPrismaRateLimitStore(database as never); + const app = Fastify(); + + await app.register(rateLimit, { + global: false, + skipOnError: false, + store: Store, + }); + await registerAuthRoutes(app, { + challengeDatabase: { + authChallenge: { + async create(args: { + data: { + challengeHash: string; + expiresAt: Date; + issuedAt: Date; + nonceHash: string; + normalizedHiveUsername: string; + provider: AuthProvider; + }; + select: { expiresAt: true; id: true }; + }) { + return { + expiresAt: args.data.expiresAt, + id: 'challenge-1', + }; + }, + async deleteMany() { + return { count: 0 }; + }, + }, + } as never, + signatureVerifier: { + async verifyPostingSignature() { + return false; + }, + }, + }); + + let refreshStatus = 0; + + for (let index = 0; index < 41; index += 1) { + const response = await app.inject({ + method: 'POST', + payload: {}, + url: '/auth/refresh', + }); + refreshStatus = response.statusCode; + } + + const challenge = await app.inject({ + method: 'POST', + payload: { + provider: 'keychain', + username: 'alice', + }, + url: '/auth/challenge', + }); + + assert.equal(refreshStatus, 429); + assert.equal(challenge.statusCode, 200, challenge.body); + assert.equal(challenge.json().hiveUsername, 'alice'); + await app.close(); + }); }); diff --git a/apps/api/src/lib/auth-rate-limit-store.ts b/apps/api/src/lib/auth-rate-limit-store.ts index f64e099..1e5208b 100644 --- a/apps/api/src/lib/auth-rate-limit-store.ts +++ b/apps/api/src/lib/auth-rate-limit-store.ts @@ -23,6 +23,10 @@ type RateLimitRouteInfo = { url?: string | undefined; }; +type RateLimitChildOptions = RateLimitRouteInfo & { + routeInfo?: RateLimitRouteInfo | undefined; +}; + type RateLimitRow = { count: number; expiresAt: Date; @@ -39,6 +43,16 @@ type RateLimitStoreConstructor = new (options?: RateLimitStoreOptions) => RateLi const DEFAULT_NAMESPACE = 'hivelore-auth-rate-limit'; const CLEANUP_INTERVAL_INCREMENTS = 100; +export function extractRateLimitRouteInfo(options: RateLimitChildOptions): RateLimitRouteInfo { + const routeInfo = options.routeInfo ?? options; + + return { + method: routeInfo.method, + path: routeInfo.path, + url: routeInfo.url, + }; +} + function routeScope(routeInfo: RateLimitStoreOptions['routeInfo']) { if (!routeInfo) { return 'global'; @@ -75,10 +89,10 @@ export function createPrismaRateLimitStore( ); } - child(routeOptions: RateLimitRouteInfo) { + child(routeOptions: RateLimitChildOptions) { return new PrismaRateLimitStore({ ...this.options, - routeInfo: routeOptions, + routeInfo: extractRateLimitRouteInfo(routeOptions), }); } diff --git a/apps/api/src/lib/auth-sessions.ts b/apps/api/src/lib/auth-sessions.ts index d8f8cf9..e4b170b 100644 --- a/apps/api/src/lib/auth-sessions.ts +++ b/apps/api/src/lib/auth-sessions.ts @@ -1,5 +1,5 @@ import type { PlatformRole as PlatformRoleType } from '../generated/prisma/enums.js'; -import type { PrismaClient, RefreshSession, User } from '../generated/prisma/client.js'; +import type { Prisma, PrismaClient, RefreshSession, User } from '../generated/prisma/client.js'; import { randomToken, sha256Hmac, signAccessToken } from './auth-crypto.js'; export type SafeUser = { @@ -167,7 +167,7 @@ export async function rotateRefreshSession( const now = input.now ?? new Date(); const tokenHash = sha256Hmac(input.refreshToken, input.refreshSecret); - return database.$transaction(async (transaction) => { + return database.$transaction(async (transaction: Prisma.TransactionClient) => { const session = await transaction.refreshSession.findUnique({ where: { refreshTokenHash: tokenHash, diff --git a/apps/api/src/lib/canon-voting-concurrency.test.ts b/apps/api/src/lib/canon-voting-concurrency.test.ts new file mode 100644 index 0000000..bc3b05e --- /dev/null +++ b/apps/api/src/lib/canon-voting-concurrency.test.ts @@ -0,0 +1,306 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { test } from 'node:test'; + +import { PrismaPg } from '@prisma/adapter-pg'; +import pg from 'pg'; + +import { PrismaClient } from '../generated/prisma/client.js'; +import { + PlatformRole, + ProposalDecisionOutcome, + ProposalStatus, + ProposalType, + WorldAuditAction, + WorldRole, +} from '../generated/prisma/enums.js'; +import { confirmCanonTransaction, CanonVotingError } from './canon-voting.js'; +import { hashCanonicalJson } from './canon-voting-policy.js'; +import { HIVELORE_CUSTOM_JSON_ID } from './hive/constants.js'; +import { buildHiveLoreCustomJsonOperation } from './hive/operations.js'; + +const { Client } = pg; + +const adminUrl = process.env.TEST_DATABASE_ADMIN_URL; + +function disposableDatabaseName() { + return `hivelore_confirm_race_${Date.now()}_${process.pid}_${randomUUID().replaceAll('-', '').slice(0, 8)}`; +} + +function databaseUrlFor(adminConnectionUrl: string, databaseName: string) { + const databaseUrl = new URL(adminConnectionUrl); + databaseUrl.pathname = `/${databaseName}`; + + return databaseUrl.toString(); +} + +async function withPgClient( + connectionString: string, + callback: (client: pg.Client) => Promise, +) { + const client = new Client({ connectionString }); + await client.connect(); + + try { + return await callback(client); + } finally { + await client.end(); + } +} + +function runPrismaMigrateDeploy(databaseUrl: string) { + const executable = process.platform === 'win32' ? 'npx.cmd' : 'npx'; + const result = spawnSync(executable, ['prisma', 'migrate', 'deploy'], { + cwd: new URL('../..', import.meta.url), + env: { + ...process.env, + DATABASE_URL: databaseUrl, + DIRECT_URL: databaseUrl, + NODE_ENV: 'test', + }, + shell: false, + stdio: 'inherit', + }); + + if (result.status !== 0) { + throw new Error(`prisma migrate deploy failed with exit code ${result.status}`); + } +} + +function createPrismaClient(connectionString: string) { + return new PrismaClient({ + adapter: new PrismaPg({ connectionString }), + }); +} + +function createBarrier(parties: number) { + let arrived = 0; + let release!: () => void; + const ready = new Promise((resolve) => { + release = resolve; + }); + + return async () => { + arrived += 1; + + if (arrived === parties) { + release(); + } + + await ready; + }; +} + +const decisionPayload = { + counts: { + alternateTimeline: 0, + approve: 7, + needsRevision: 0, + reject: 3, + total: 10, + }, + eventType: 'canon_decision', + outcome: ProposalDecisionOutcome.APPROVED_FOR_PUBLICATION, + proposalId: 'proposal-race', + worldId: 'world-race', +}; + +function confirmedOperation(transactionId: string) { + return { + blockNumber: BigInt(120), + blockchainTimestamp: new Date('2026-08-12T12:05:00.000Z'), + operation: buildHiveLoreCustomJsonOperation({ + action: 'canon_approval', + entityId: 'decision-race', + entityType: 'CANON_DECISION', + payload: decisionPayload, + proposalId: 'proposal-race', + signer: 'mira-vale.dev', + worldId: 'world-race', + }), + operationIndex: 0, + transactionId, + }; +} + +async function seedConfirmedProposal(database: PrismaClient) { + await database.user.create({ + data: { + hiveUsername: 'mira-vale.dev', + id: 'author-race', + normalizedHiveUsername: 'mira-vale.dev', + platformRole: PlatformRole.USER, + }, + }); + await database.world.create({ + data: { + description: 'Race test world', + founderId: 'author-race', + id: 'world-race', + slug: 'race-test-world', + title: 'Race Test World', + }, + }); + await database.worldMembership.create({ + data: { + id: 'membership-race', + role: WorldRole.CONTRIBUTOR, + userId: 'author-race', + worldId: 'world-race', + }, + }); + await database.proposal.create({ + data: { + authorId: 'author-race', + contentHash: 'content-hash', + decidedAt: new Date('2026-08-12T12:00:00.000Z'), + id: 'proposal-race', + proposalType: ProposalType.ADD_LORE, + proposedContent: { text: 'canon proposal' }, + status: ProposalStatus.APPROVED_FOR_PUBLICATION, + summary: 'Race proposal', + title: 'Race proposal', + votingEndsAt: new Date('2026-08-12T12:00:00.000Z'), + votingStartedAt: new Date('2026-08-10T12:00:00.000Z'), + worldId: 'world-race', + }, + }); + await database.proposalDecision.create({ + data: { + aiWarningAcknowledged: false, + alternateTimelineCount: 0, + approvalDenominator: 10, + approvalNumerator: 7, + approvalPercentageBps: 7000, + approvalThresholdBps: 7000, + approveCount: 7, + contentHash: 'content-hash', + customJsonId: HIVELORE_CUSTOM_JSON_ID, + decidedAt: new Date('2026-08-12T12:00:00.000Z'), + decisionPayload, + decisionPayloadHash: hashCanonicalJson(decisionPayload), + expectedSigner: 'mira-vale.dev', + id: 'decision-race', + minimumVotes: 5, + needsRevisionCount: 0, + outcome: ProposalDecisionOutcome.APPROVED_FOR_PUBLICATION, + payloadSchemaVersion: 2, + proposalId: 'proposal-race', + rejectCount: 3, + rulesVersion: 'canon-voting-mvp-2026-08-12', + totalVotes: 10, + votingWindowHours: 48, + }, + }); +} + +test( + 'canon decision confirmation is atomic across concurrent PostgreSQL clients', + { skip: !adminUrl ? 'TEST_DATABASE_ADMIN_URL is not configured.' : false }, + async () => { + assert.ok(adminUrl); + const databaseName = disposableDatabaseName(); + + if (!databaseName.startsWith('hivelore_confirm_race_')) { + throw new Error(`Refusing to use non-disposable database name: ${databaseName}`); + } + + const directUrl = databaseUrlFor(adminUrl, databaseName); + + await withPgClient(adminUrl, (client) => client.query(`CREATE DATABASE "${databaseName}"`)); + + try { + runPrismaMigrateDeploy(directUrl); + + const seedClient = createPrismaClient(directUrl); + await seedConfirmedProposal(seedClient); + await seedClient.$disconnect(); + + const firstClient = createPrismaClient(directUrl); + const secondClient = createPrismaClient(directUrl); + const synchronizeLookup = createBarrier(2); + + const first = confirmCanonTransaction(firstClient, { + actorId: 'author-race', + hafClient: {} as never, + hiveBroadcaster: { + async confirmTransactionOperation() { + await synchronizeLookup(); + return confirmedOperation('tx-race-a'); + }, + } as never, + operationIndex: 0, + proposalId: 'proposal-race', + transactionId: 'tx-race-a', + worldId: 'world-race', + }); + const second = confirmCanonTransaction(secondClient, { + actorId: 'author-race', + hafClient: {} as never, + hiveBroadcaster: { + async confirmTransactionOperation() { + await synchronizeLookup(); + return confirmedOperation('tx-race-b'); + }, + } as never, + operationIndex: 0, + proposalId: 'proposal-race', + transactionId: 'tx-race-b', + worldId: 'world-race', + }); + + const results = await Promise.allSettled([first, second]); + const fulfilled = results.filter((result) => result.status === 'fulfilled'); + const rejected = results.filter((result) => result.status === 'rejected'); + + assert.equal(fulfilled.length, 1); + assert.equal(rejected.length, 1); + assert.ok( + rejected[0]?.status === 'rejected' && + rejected[0].reason instanceof CanonVotingError && + rejected[0].reason.code === 'DECISION_ALREADY_CONFIRMED', + ); + + const verificationClient = createPrismaClient(directUrl); + const [decision, eventCount, auditCount] = await Promise.all([ + verificationClient.proposalDecision.findUnique({ + where: { + id: 'decision-race', + }, + }), + verificationClient.hiveEvent.count(), + verificationClient.worldAuditLog.count({ + where: { + action: WorldAuditAction.CANON_DECISION_CONFIRMED, + }, + }), + ]); + + assert.ok(decision?.transactionId === 'tx-race-a' || decision?.transactionId === 'tx-race-b'); + assert.equal(decision?.operationIndex, 0); + assert.ok(decision?.hiveEventId); + assert.equal(eventCount, 1); + assert.equal(auditCount, 1); + + await Promise.all([ + firstClient.$disconnect(), + secondClient.$disconnect(), + verificationClient.$disconnect(), + ]); + } finally { + await withPgClient(adminUrl, async (client) => { + await client.query( + ` + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 + AND pid <> pg_backend_pid() + `, + [databaseName], + ); + await client.query(`DROP DATABASE IF EXISTS "${databaseName}"`); + }); + } + }, +); diff --git a/apps/api/src/lib/canon-voting-policy.test.ts b/apps/api/src/lib/canon-voting-policy.test.ts new file mode 100644 index 0000000..3a315cd --- /dev/null +++ b/apps/api/src/lib/canon-voting-policy.test.ts @@ -0,0 +1,219 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { ProposalDecisionOutcome, VoteChoice } from '../generated/prisma/enums.js'; +import { + addVotingWindow, + CANON_VOTING_RULES, + decideCanonOutcome, + getVotingWindowState, + hashCanonicalJson, + tallyCanonVotes, +} from './canon-voting-policy.js'; + +const startedAt = new Date('2026-08-10T00:00:00.000Z'); +const votingEndsAt = addVotingWindow(startedAt); +const afterEnd = new Date(votingEndsAt.getTime() + 1); + +function votes(...choices: VoteChoice[]) { + return choices.map((choice) => ({ choice })); +} + +function voterVotes(...entries: Array<[string, VoteChoice]>) { + return entries.map(([voterId, choice]) => ({ choice, voterId })); +} + +describe('canon voting policy', () => { + test('uses a 48 hour voting window and does not decide before the end', () => { + assert.equal(votingEndsAt.toISOString(), '2026-08-12T00:00:00.000Z'); + assert.equal( + getVotingWindowState({ + now: new Date(votingEndsAt.getTime() - 1), + votingEndsAt, + }), + 'BEFORE_END', + ); + assert.equal(getVotingWindowState({ now: votingEndsAt, votingEndsAt }), 'AT_OR_AFTER_END'); + assert.equal( + decideCanonOutcome({ + now: new Date(votingEndsAt.getTime() - 1), + tally: tallyCanonVotes(votes(VoteChoice.APPROVE, VoteChoice.APPROVE)), + votingEndsAt, + }), + null, + ); + }); + + test('exactly five votes and exactly 70 percent approval passes at the window boundary', () => { + const tally = tallyCanonVotes( + votes( + VoteChoice.APPROVE, + VoteChoice.APPROVE, + VoteChoice.APPROVE, + VoteChoice.APPROVE, + VoteChoice.APPROVE, + VoteChoice.APPROVE, + VoteChoice.APPROVE, + VoteChoice.REJECT, + VoteChoice.REJECT, + VoteChoice.REJECT, + ), + ); + + assert.equal(tally.totalVotes, 10); + assert.equal(tally.approvalPercentageBps, CANON_VOTING_RULES.approvalThresholdBps); + assert.equal( + decideCanonOutcome({ now: votingEndsAt, tally, votingEndsAt }), + ProposalDecisionOutcome.APPROVED_FOR_PUBLICATION, + ); + }); + + test('fewer than five votes fails even at 100 percent approval', () => { + const tally = tallyCanonVotes( + votes(VoteChoice.APPROVE, VoteChoice.APPROVE, VoteChoice.APPROVE, VoteChoice.APPROVE), + ); + + assert.equal(tally.approvalPercentageBps, 10_000); + assert.equal( + decideCanonOutcome({ now: afterEnd, tally, votingEndsAt }), + ProposalDecisionOutcome.REJECTED, + ); + }); + + test('zero approve produces zero percent instead of NaN', () => { + const tally = tallyCanonVotes( + votes(VoteChoice.NEEDS_REVISION, VoteChoice.NEEDS_REVISION, VoteChoice.ALTERNATE_TIMELINE), + ); + + assert.equal(tally.totalVotes, 3); + assert.equal(tally.approvalDenominator, 3); + assert.equal(tally.approvalPercentageBps, 0); + assert.equal( + decideCanonOutcome({ now: afterEnd, tally, votingEndsAt }), + ProposalDecisionOutcome.NEEDS_REVISION, + ); + }); + + test('revision and alternate votes count toward participation and approval denominator', () => { + const tally = tallyCanonVotes( + votes( + VoteChoice.APPROVE, + VoteChoice.REJECT, + VoteChoice.NEEDS_REVISION, + VoteChoice.ALTERNATE_TIMELINE, + VoteChoice.ALTERNATE_TIMELINE, + ), + ); + + assert.equal(tally.totalVotes, 5); + assert.equal(tally.approvalDenominator, 5); + assert.equal(tally.approvalNumerator, 1); + assert.equal(tally.approvalPercentageBps, 2_000); + }); + + test('constructive dissent prevents approval instead of disappearing from the denominator', () => { + const tally = tallyCanonVotes( + votes( + VoteChoice.APPROVE, + VoteChoice.NEEDS_REVISION, + VoteChoice.NEEDS_REVISION, + VoteChoice.NEEDS_REVISION, + VoteChoice.NEEDS_REVISION, + ), + ); + + assert.equal(tally.totalVotes, 5); + assert.equal(tally.approvalDenominator, 5); + assert.equal(tally.approvalPercentageBps, 2_000); + assert.equal( + decideCanonOutcome({ now: afterEnd, tally, votingEndsAt }), + ProposalDecisionOutcome.NEEDS_REVISION, + ); + }); + + test('uses deterministic failed-outcome plurality with stable tie order', () => { + assert.equal( + decideCanonOutcome({ + now: afterEnd, + tally: tallyCanonVotes( + votes(VoteChoice.NEEDS_REVISION, VoteChoice.ALTERNATE_TIMELINE, VoteChoice.REJECT), + ), + votingEndsAt, + }), + ProposalDecisionOutcome.NEEDS_REVISION, + ); + + assert.equal( + decideCanonOutcome({ + now: afterEnd, + tally: tallyCanonVotes( + votes(VoteChoice.ALTERNATE_TIMELINE, VoteChoice.ALTERNATE_TIMELINE, VoteChoice.REJECT), + ), + votingEndsAt, + }), + ProposalDecisionOutcome.ALTERNATE_TIMELINE, + ); + }); + + test('stale base canon conflict is surfaced before applying a normal outcome', () => { + assert.equal( + decideCanonOutcome({ + now: afterEnd, + staleBaseAtDecision: true, + tally: tallyCanonVotes( + votes( + VoteChoice.APPROVE, + VoteChoice.APPROVE, + VoteChoice.APPROVE, + VoteChoice.APPROVE, + VoteChoice.APPROVE, + ), + ), + votingEndsAt, + }), + ProposalDecisionOutcome.STALE_BASE_CONFLICT, + ); + }); + + test('canonical json hashing is deterministic across object key order', () => { + assert.equal(hashCanonicalJson({ b: 2, a: 1 }), hashCanonicalJson({ a: 1, b: 2 })); + }); + + test('excludes proposal author votes from approval math and participation', () => { + const tally = tallyCanonVotes( + voterVotes( + ['author-1', VoteChoice.APPROVE], + ['reader-1', VoteChoice.APPROVE], + ['reader-2', VoteChoice.APPROVE], + ['reader-3', VoteChoice.APPROVE], + ['reader-4', VoteChoice.REJECT], + ['reader-5', VoteChoice.REJECT], + ), + { excludeVoterIds: new Set(['author-1']) }, + ); + + assert.equal(tally.totalVotes, 5); + assert.equal(tally.approve, 3); + assert.equal(tally.reject, 2); + assert.equal(tally.approvalPercentageBps, 6_000); + }); + + test('legacy author votes cannot satisfy the five-vote minimum', () => { + const tally = tallyCanonVotes( + voterVotes( + ['author-1', VoteChoice.APPROVE], + ['reader-1', VoteChoice.APPROVE], + ['reader-2', VoteChoice.APPROVE], + ['reader-3', VoteChoice.APPROVE], + ['reader-4', VoteChoice.APPROVE], + ), + { excludeVoterIds: new Set(['author-1']) }, + ); + + assert.equal(tally.totalVotes, 4); + assert.equal( + decideCanonOutcome({ now: afterEnd, tally, votingEndsAt }), + ProposalDecisionOutcome.REJECTED, + ); + }); +}); diff --git a/apps/api/src/lib/canon-voting-policy.ts b/apps/api/src/lib/canon-voting-policy.ts new file mode 100644 index 0000000..a035dc5 --- /dev/null +++ b/apps/api/src/lib/canon-voting-policy.ts @@ -0,0 +1,164 @@ +import { createHash } from 'node:crypto'; + +import { ProposalDecisionOutcome, VoteChoice } from '../generated/prisma/enums.js'; + +export const CANON_VOTING_RULES = { + approvalThresholdBps: 7_000, + minimumVotes: 5, + payloadSchemaVersion: 2, + rulesVersion: 'canon-voting-mvp-2026-08-12', + votingWindowHours: 48, +} as const; + +export type CanonVoteCounts = { + approve: number; + reject: number; + needsRevision: number; + alternateTimeline: number; +}; + +export type CanonTally = CanonVoteCounts & { + approvalDenominator: number; + approvalNumerator: number; + approvalPercentageBps: number; + totalVotes: number; +}; + +export type CanonVotingWindowState = 'BEFORE_END' | 'AT_OR_AFTER_END'; + +export function addVotingWindow(startedAt: Date) { + return new Date(startedAt.getTime() + CANON_VOTING_RULES.votingWindowHours * 60 * 60 * 1_000); +} + +export function getVotingWindowState(input: { now: Date; votingEndsAt: Date }) { + return input.now.getTime() < input.votingEndsAt.getTime() ? 'BEFORE_END' : 'AT_OR_AFTER_END'; +} + +export function tallyCanonVotes( + votes: Array<{ choice: VoteChoice; voterId?: string | null }>, + options: { excludeVoterIds?: ReadonlySet } = {}, +): CanonTally { + const counts: CanonVoteCounts = { + alternateTimeline: 0, + approve: 0, + needsRevision: 0, + reject: 0, + }; + + for (const vote of votes) { + if (vote.voterId && options.excludeVoterIds?.has(vote.voterId)) { + continue; + } + + if (vote.choice === VoteChoice.APPROVE) { + counts.approve += 1; + } else if (vote.choice === VoteChoice.REJECT) { + counts.reject += 1; + } else if (vote.choice === VoteChoice.NEEDS_REVISION) { + counts.needsRevision += 1; + } else if (vote.choice === VoteChoice.ALTERNATE_TIMELINE) { + counts.alternateTimeline += 1; + } + } + + const approvalDenominator = + counts.approve + counts.reject + counts.needsRevision + counts.alternateTimeline; + const totalVotes = approvalDenominator; + const approvalPercentageBps = + approvalDenominator > 0 ? Math.floor((counts.approve * 10_000) / approvalDenominator) : 0; + + return { + ...counts, + approvalDenominator, + approvalNumerator: counts.approve, + approvalPercentageBps, + totalVotes, + }; +} + +export function decideCanonOutcome(input: { + staleBaseAtDecision?: boolean; + tally: CanonTally; + votingEndsAt: Date; + now: Date; +}) { + if (getVotingWindowState(input) === 'BEFORE_END') { + return null; + } + + if (input.staleBaseAtDecision) { + return ProposalDecisionOutcome.STALE_BASE_CONFLICT; + } + + const passesApproval = + input.tally.totalVotes >= CANON_VOTING_RULES.minimumVotes && + input.tally.approvalPercentageBps >= CANON_VOTING_RULES.approvalThresholdBps; + + if (passesApproval) { + return ProposalDecisionOutcome.APPROVED_FOR_PUBLICATION; + } + + const feedbackChoices = [ + { + choice: ProposalDecisionOutcome.NEEDS_REVISION, + count: input.tally.needsRevision, + rank: 1, + }, + { + choice: ProposalDecisionOutcome.ALTERNATE_TIMELINE, + count: input.tally.alternateTimeline, + rank: 2, + }, + { + choice: ProposalDecisionOutcome.REJECTED, + count: input.tally.reject, + rank: 3, + }, + ].sort((left, right) => right.count - left.count || left.rank - right.rank); + + if (feedbackChoices[0] && feedbackChoices[0].count > 0) { + return feedbackChoices[0].choice; + } + + return ProposalDecisionOutcome.REJECTED; +} + +export function proposalStatusForDecision(outcome: ProposalDecisionOutcome) { + if (outcome === ProposalDecisionOutcome.APPROVED_FOR_PUBLICATION) { + return 'APPROVED_FOR_PUBLICATION' as const; + } + + if (outcome === ProposalDecisionOutcome.ALTERNATE_TIMELINE) { + return 'ALTERNATE_TIMELINE' as const; + } + + if ( + outcome === ProposalDecisionOutcome.NEEDS_REVISION || + outcome === ProposalDecisionOutcome.STALE_BASE_CONFLICT + ) { + return 'NEEDS_REVISION' as const; + } + + return 'REJECTED' as const; +} + +export function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + + if (value && typeof value === 'object') { + const record = value as Record; + + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) + .join(',')}}`; + } + + return JSON.stringify(value); +} + +export function hashCanonicalJson(value: unknown) { + return createHash('sha256').update(stableStringify(value), 'utf8').digest('hex'); +} diff --git a/apps/api/src/lib/canon-voting.test.ts b/apps/api/src/lib/canon-voting.test.ts new file mode 100644 index 0000000..d994a65 --- /dev/null +++ b/apps/api/src/lib/canon-voting.test.ts @@ -0,0 +1,788 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { + ProposalDecisionOutcome, + ProposalStatus, + VoteChoice, + WorldRole, +} from '../generated/prisma/enums.js'; +import { HIVELORE_CUSTOM_JSON_ID } from './hive/constants.js'; +import { buildHiveLoreCustomJsonOperation } from './hive/operations.js'; +import { + acknowledgeProposalAiWarning, + CanonVotingError, + castCanonVote, + confirmCanonTransaction, + createCanonTransactionOperation, + finalizeCanonDecision, + getProposalDetail, +} from './canon-voting.js'; +import { hashCanonicalJson } from './canon-voting-policy.js'; + +const decisionPayload = { + eventType: 'canon_decision', + proposalId: 'proposal-1', + worldId: 'world-1', + outcome: ProposalDecisionOutcome.APPROVED_FOR_PUBLICATION, + counts: { + alternateTimeline: 0, + approve: 7, + needsRevision: 0, + reject: 3, + total: 10, + }, +}; + +function createDecision(overrides: Record = {}) { + return { + aiWarningAcknowledged: false, + aiWarningSummary: null, + alternateTimelineCount: 0, + approvalDenominator: 10, + approvalNumerator: 7, + approvalPercentageBps: 7000, + approvalThresholdBps: 7000, + approveCount: 7, + baseCanonVersionId: null, + blockchainTimestamp: null, + blockNumber: null, + branchBaseLoreEntryId: null, + branchLabel: null, + branchParentProposalId: null, + conflictMetadata: null, + contentHash: 'content-hash', + createdAt: new Date('2026-08-10T12:00:00.000Z'), + customJsonId: HIVELORE_CUSTOM_JSON_ID, + decidedAt: new Date('2026-08-12T12:00:00.000Z'), + decisionPayload, + decisionPayloadHash: hashCanonicalJson(decisionPayload), + expectedSigner: 'mira-vale.dev', + hiveEventId: null, + id: 'decision-1', + minimumVotes: 5, + needsRevisionCount: 0, + operationIndex: null, + outcome: ProposalDecisionOutcome.APPROVED_FOR_PUBLICATION, + payloadSchemaVersion: 1, + proposalId: 'proposal-1', + rejectCount: 3, + rulesVersion: 'canon-voting-mvp-2026-08-10', + staleBaseAtDecision: false, + totalVotes: 10, + transactionId: null, + updatedAt: new Date('2026-08-12T12:00:00.000Z'), + votingWindowHours: 48, + ...overrides, + }; +} + +function createOperation(input: { + authority?: 'active' | 'posting'; + proposalId?: string; + signer?: string; + transactionId?: string; +}) { + return { + block_num: 100, + operation_id: 0, + operation: buildHiveLoreCustomJsonOperation({ + action: 'canon_approval', + authority: input.authority ?? 'posting', + entityId: 'decision-1', + entityType: 'CANON_DECISION', + payload: decisionPayload, + proposalId: input.proposalId ?? 'proposal-1', + signer: input.signer ?? 'mira-vale.dev', + worldId: 'world-1', + }), + timestamp: '2026-08-12T12:05:00.000Z', + transaction_id: input.transactionId ?? 'tx-valid', + }; +} + +type ConfirmationMembership = { + revokedAt: Date | null; + role: WorldRole; + userId: string; + worldId: string; +}; + +function createDatabase( + decision = createDecision(), + memberships: ConfirmationMembership[] = [ + { + revokedAt: null, + role: WorldRole.CONTRIBUTOR, + userId: 'author-1', + worldId: 'world-1', + }, + ], +) { + const auditLogs: unknown[] = []; + const events: unknown[] = []; + const proposal = { + author: { + hiveUsername: 'mira-vale.dev', + id: 'author-1', + }, + authorId: 'author-1', + decision, + id: 'proposal-1', + worldId: 'world-1', + }; + + const database = { + hiveEvent: { + async upsert(args: { + create: { id?: string; operationIndex: number; transactionId: string }; + }) { + const event = { + id: `event-${events.length + 1}`, + ...args.create, + }; + events.push(event); + return event; + }, + }, + proposal: { + async findFirst(args: { where: { id: string; worldId: string } }) { + if (args.where.id !== proposal.id || args.where.worldId !== proposal.worldId) { + return null; + } + + return proposal; + }, + }, + proposalDecision: { + async findUnique() { + return decision; + }, + async update(args: { data: Record }) { + Object.assign(decision, args.data); + return decision; + }, + async updateMany(args: { + data: Record; + where: { hiveEventId?: null; id: string; operationIndex: null; transactionId: null }; + }) { + if ( + args.where.id !== decision.id || + ('hiveEventId' in args.where && decision.hiveEventId !== null) || + decision.operationIndex !== null || + decision.transactionId !== null + ) { + return { count: 0 }; + } + + Object.assign(decision, args.data); + return { count: 1 }; + }, + }, + worldMembership: { + async findUnique(args: { + where: { revokedAt: null; worldId_userId: { userId: string; worldId: string } }; + }) { + return ( + memberships.find( + (membership) => + membership.userId === args.where.worldId_userId.userId && + membership.worldId === args.where.worldId_userId.worldId && + membership.revokedAt === null, + ) ?? null + ); + }, + }, + worldAuditLog: { + async create(args: { data: unknown }) { + auditLogs.push(args.data); + return args.data; + }, + }, + $transaction(callback: (transaction: unknown) => Promise) { + return callback(database); + }, + }; + + return { + auditLogs, + database, + decision, + events, + }; +} + +function createHafClient(row: unknown) { + return { + async searchBlocks() { + return { + operations: [row], + }; + }, + }; +} + +const votingStartedAt = new Date('2026-08-10T12:00:00.000Z'); +const votingEndsAt = new Date('2026-08-12T12:00:00.000Z'); +const afterVotingEnds = new Date('2026-08-12T12:01:00.000Z'); + +type ServiceVote = { + choice: VoteChoice; + createdAt: Date; + id: string; + proposalId: string; + updatedAt: Date; + voterId: string; +}; + +type ServiceProposal = Record & { + aiReports: Array<{ findings: unknown; summary: string | null }>; + aiWarningAcknowledgedAt: Date | null; + authorId: string; + decision: unknown | null; + id: string; + votes: ServiceVote[]; + worldId: string; +}; + +function createServiceProposal(overrides: Partial = {}): ServiceProposal { + return { + aiReports: [], + aiWarningAcknowledgedAt: null, + approvedAt: null, + author: { + hiveUsername: 'mira-vale.dev', + id: 'author-1', + }, + authorId: 'author-1', + baseCanonVersionId: null, + branchBaseLoreEntryId: null, + branchLabel: null, + branchParentProposalId: null, + conflictMetadata: null, + contentHash: 'content-hash', + decidedAt: null, + decision: null, + id: 'proposal-1', + proposedContent: { text: 'canon proposal' }, + proposalType: 'LORE_ENTRY', + rejectedAt: null, + staleBaseAtDecision: false, + status: ProposalStatus.VOTING, + submittedAt: votingStartedAt, + summary: 'A proposal', + title: 'Proposal', + votes: [], + votingEndsAt, + votingStartedAt, + world: { + id: 'world-1', + title: 'World', + }, + worldId: 'world-1', + ...overrides, + }; +} + +function createVote(voterId: string, choice: VoteChoice = VoteChoice.APPROVE): ServiceVote { + return { + choice, + createdAt: votingStartedAt, + id: `vote-${voterId}`, + proposalId: 'proposal-1', + updatedAt: votingStartedAt, + voterId, + }; +} + +function createServiceDatabase(proposal = createServiceProposal()) { + const auditLogs: unknown[] = []; + const decisions: unknown[] = []; + + const database = { + appVote: { + async upsert(args: { + create: { choice: VoteChoice; proposalId: string; voterId: string }; + update: { choice: VoteChoice }; + where: { proposalId_voterId: { voterId: string } }; + }) { + const existing = proposal.votes.find( + (vote) => vote.voterId === args.where.proposalId_voterId.voterId, + ); + + if (existing) { + existing.choice = args.update.choice; + existing.updatedAt = afterVotingEnds; + return existing; + } + + const vote = { + ...createVote(args.create.voterId, args.create.choice), + createdAt: afterVotingEnds, + updatedAt: afterVotingEnds, + }; + proposal.votes.push(vote); + return vote; + }, + }, + proposal: { + async findFirst(args: { where: { id: string; worldId: string } }) { + if (args.where.id !== proposal.id || args.where.worldId !== proposal.worldId) { + return null; + } + + return proposal; + }, + async update(args: { data: Record }) { + Object.assign(proposal, args.data); + return proposal; + }, + }, + proposalDecision: { + async create(args: { data: Record }) { + const decision = { + blockchainTimestamp: null, + blockNumber: null, + createdAt: afterVotingEnds, + customJsonId: null, + expectedSigner: null, + hiveEventId: null, + id: `decision-${decisions.length + 1}`, + operationIndex: null, + transactionId: null, + updatedAt: afterVotingEnds, + ...args.data, + }; + decisions.push(decision); + proposal.decision = decision; + return decision; + }, + async findUnique() { + return decisions[0] ?? null; + }, + }, + worldAuditLog: { + async create(args: { data: unknown }) { + auditLogs.push(args.data); + return args.data; + }, + }, + worldBibleVersion: { + async findFirst() { + return null; + }, + }, + $transaction(callback: (transaction: unknown) => Promise) { + return callback(database); + }, + }; + + return { + auditLogs, + database, + decisions, + proposal, + }; +} + +describe('canon voting Hive confirmation', () => { + test('confirms a matching posting-authority custom_json and is idempotent for the same operation', async () => { + const state = createDatabase(); + const hafClient = createHafClient(createOperation({ transactionId: 'tx-valid' })); + + const confirmed = await confirmCanonTransaction(state.database as never, { + actorId: 'author-1', + blockNumber: 100, + hafClient: hafClient as never, + operationIndex: 0, + proposalId: 'proposal-1', + transactionId: 'tx-valid', + worldId: 'world-1', + }); + const repeated = await confirmCanonTransaction(state.database as never, { + actorId: 'author-1', + blockNumber: 100, + hafClient: hafClient as never, + operationIndex: 0, + proposalId: 'proposal-1', + transactionId: 'tx-valid', + worldId: 'world-1', + }); + + assert.equal(confirmed.idempotent, false); + assert.ok(confirmed.decision); + assert.equal(confirmed.decision.transactionId, 'tx-valid'); + assert.equal( + confirmed.decision.blockchainTimestamp, + new Date('2026-08-12T12:05:00.000Z').toISOString(), + ); + assert.equal(repeated.idempotent, true); + assert.equal(state.events.length, 1); + assert.equal( + (state.events[0] as { blockchainTimestamp: Date }).blockchainTimestamp.toISOString(), + '2026-08-12T12:05:00.000Z', + ); + assert.equal(state.auditLogs.length, 1); + }); + + test('rejects a different operation once the decision already has a Hive confirmation', async () => { + const state = createDatabase( + createDecision({ + operationIndex: 0, + transactionId: 'tx-original', + }), + ); + + await assert.rejects( + confirmCanonTransaction(state.database as never, { + actorId: 'author-1', + blockNumber: 101, + hafClient: createHafClient(createOperation({ transactionId: 'tx-other' })) as never, + operationIndex: 0, + proposalId: 'proposal-1', + transactionId: 'tx-other', + worldId: 'world-1', + }), + (error: unknown) => + error instanceof CanonVotingError && error.code === 'DECISION_ALREADY_CONFIRMED', + ); + + assert.equal(state.decision.transactionId, 'tx-original'); + assert.equal(state.events.length, 0); + }); + + test('rejects active-authority custom_json for canon decision confirmation', async () => { + const state = createDatabase(); + + await assert.rejects( + confirmCanonTransaction(state.database as never, { + actorId: 'author-1', + blockNumber: 100, + hafClient: createHafClient( + createOperation({ authority: 'active', transactionId: 'tx-active' }), + ) as never, + operationIndex: 0, + proposalId: 'proposal-1', + transactionId: 'tx-active', + worldId: 'world-1', + }), + (error: unknown) => + error instanceof CanonVotingError && error.code === 'HIVE_OPERATION_INVALID_AUTHORITY', + ); + + assert.equal(state.events.length, 0); + }); + + test('skips unrelated malformed HAF rows and confirms the matching operation', async () => { + const state = createDatabase(); + const hafClient = { + async searchBlocks() { + return { + operations: [ + { + block_num: 100, + operation_id: 0, + transaction_id: 'tx-unrelated', + }, + createOperation({ transactionId: 'tx-valid' }), + ], + }; + }, + }; + + const confirmed = await confirmCanonTransaction(state.database as never, { + actorId: 'author-1', + blockNumber: 100, + hafClient: hafClient as never, + operationIndex: 0, + proposalId: 'proposal-1', + transactionId: 'tx-valid', + worldId: 'world-1', + }); + + assert.equal(confirmed.decision?.transactionId, 'tx-valid'); + }); + + test('returns not found when a mixed Hive block does not contain the target operation', async () => { + const state = createDatabase(); + + await assert.rejects( + confirmCanonTransaction(state.database as never, { + actorId: 'author-1', + blockNumber: 100, + hafClient: createHafClient({ + block_num: 100, + operation_id: 0, + transaction_id: 'tx-other', + }) as never, + operationIndex: 0, + proposalId: 'proposal-1', + transactionId: 'tx-missing', + worldId: 'world-1', + }), + (error: unknown) => + error instanceof CanonVotingError && error.code === 'HIVE_OPERATION_NOT_FOUND', + ); + }); + + test('rejects confirmations by users other than the proposal author', async () => { + const state = createDatabase(); + + await assert.rejects( + confirmCanonTransaction(state.database as never, { + actorId: 'other-user', + blockNumber: 100, + hafClient: createHafClient(createOperation({ transactionId: 'tx-valid' })) as never, + operationIndex: 0, + proposalId: 'proposal-1', + transactionId: 'tx-valid', + worldId: 'world-1', + }), + (error: unknown) => + error instanceof CanonVotingError && error.code === 'CONFIRMER_NOT_AUTHOR', + ); + + assert.equal(state.events.length, 0); + assert.equal(state.auditLogs.length, 0); + }); + + test('rejects the proposal author when their world role cannot submit proposals', async () => { + const state = createDatabase(createDecision(), [ + { + revokedAt: null, + role: WorldRole.READER, + userId: 'author-1', + worldId: 'world-1', + }, + ]); + let hafLookups = 0; + + await assert.rejects( + confirmCanonTransaction(state.database as never, { + actorId: 'author-1', + blockNumber: 100, + hafClient: { + async searchBlocks() { + hafLookups += 1; + return { + operations: [createOperation({ transactionId: 'tx-valid' })], + }; + }, + } as never, + operationIndex: 0, + proposalId: 'proposal-1', + transactionId: 'tx-valid', + worldId: 'world-1', + }), + (error: unknown) => + error instanceof CanonVotingError && error.code === 'INSUFFICIENT_WORLD_PERMISSIONS', + ); + + assert.equal(hafLookups, 0); + assert.equal(state.decision.transactionId, null); + assert.equal(state.events.length, 0); + assert.equal(state.auditLogs.length, 0); + }); + + test('rejects a stale concurrent confirmation claim without creating another permanent record', async () => { + const state = createDatabase( + createDecision({ + operationIndex: 0, + transactionId: 'tx-original', + }), + ); + + await assert.rejects( + confirmCanonTransaction(state.database as never, { + actorId: 'author-1', + blockNumber: 100, + hafClient: createHafClient(createOperation({ transactionId: 'tx-loser' })) as never, + operationIndex: 0, + proposalId: 'proposal-1', + transactionId: 'tx-loser', + worldId: 'world-1', + }), + (error: unknown) => + error instanceof CanonVotingError && error.code === 'DECISION_ALREADY_CONFIRMED', + ); + + assert.equal(state.decision.transactionId, 'tx-original'); + assert.equal(state.events.length, 0); + assert.equal(state.auditLogs.length, 0); + }); + + test('does not reissue a signable canon transaction once a decision is confirmed', async () => { + const state = createDatabase( + createDecision({ + hiveEventId: 'event-1', + operationIndex: 0, + transactionId: 'tx-original', + }), + ); + + await assert.rejects( + createCanonTransactionOperation(state.database as never, { + proposalId: 'proposal-1', + signerId: 'author-1', + worldId: 'world-1', + }), + (error: unknown) => + error instanceof CanonVotingError && error.code === 'DECISION_ALREADY_CONFIRMED', + ); + + assert.equal(state.decision.transactionId, 'tx-original'); + assert.equal(state.events.length, 0); + assert.equal(state.auditLogs.length, 0); + }); +}); + +describe('canon voting service invariants', () => { + test('rejects proposal author self-votes even when the author has voting permission upstream', async () => { + const state = createServiceDatabase(); + + await assert.rejects( + castCanonVote(state.database as never, { + choice: VoteChoice.APPROVE, + now: votingStartedAt, + proposalId: 'proposal-1', + voterId: 'author-1', + worldId: 'world-1', + }), + (error: unknown) => + error instanceof CanonVotingError && error.code === 'PROPOSAL_AUTHOR_CANNOT_VOTE', + ); + }); + + test('allows non-author votes through the service path', async () => { + const state = createServiceDatabase(); + + const result = await castCanonVote(state.database as never, { + choice: VoteChoice.REJECT, + now: votingStartedAt, + proposalId: 'proposal-1', + voterId: 'reader-1', + worldId: 'world-1', + }); + + assert.equal(result.vote.choice, VoteChoice.REJECT); + assert.equal(state.proposal.votes.length, 1); + }); + + test('excludes legacy author votes when finalizing the frozen result', async () => { + const state = createServiceDatabase( + createServiceProposal({ + votes: [ + createVote('author-1', VoteChoice.APPROVE), + createVote('reader-1', VoteChoice.APPROVE), + createVote('reader-2', VoteChoice.APPROVE), + createVote('reader-3', VoteChoice.APPROVE), + createVote('reader-4', VoteChoice.APPROVE), + ], + }), + ); + + const result = await finalizeCanonDecision(state.database as never, { + actorId: 'founder-1', + now: afterVotingEnds, + proposalId: 'proposal-1', + worldId: 'world-1', + }); + + assert.equal(result.decision?.outcome, ProposalDecisionOutcome.REJECTED); + assert.equal(result.decision?.totalVotes, 4); + }); + + test('does not classify a negated contradiction summary as an AI warning', async () => { + const state = createServiceDatabase( + createServiceProposal({ + aiReports: [ + { + findings: { notes: 'Contradiction keyword appears in explanation only.' }, + summary: 'No contradiction found.', + }, + ], + }), + ); + + const detail = await getProposalDetail(state.database as never, { + proposalId: 'proposal-1', + worldId: 'world-1', + }); + + assert.equal(detail.aiWarning.acknowledgmentRequired, false); + assert.equal(detail.aiWarning.acknowledged, false); + }); + + test('requires explicit acknowledgment before finalizing a major AI warning', async () => { + const state = createServiceDatabase( + createServiceProposal({ + aiReports: [ + { + findings: [{ category: 'continuity', severity: 'major', summary: 'Major conflict.' }], + summary: 'Major continuity conflict.', + }, + ], + votes: [ + createVote('reader-1'), + createVote('reader-2'), + createVote('reader-3'), + createVote('reader-4'), + createVote('reader-5'), + ], + }), + ); + + await assert.rejects( + finalizeCanonDecision(state.database as never, { + actorId: 'founder-1', + now: afterVotingEnds, + proposalId: 'proposal-1', + worldId: 'world-1', + }), + (error: unknown) => + error instanceof CanonVotingError && error.code === 'AI_WARNING_ACKNOWLEDGMENT_REQUIRED', + ); + }); + + test('records acknowledgment timestamp and freezes it into the decision payload', async () => { + const state = createServiceDatabase( + createServiceProposal({ + aiReports: [ + { + findings: [{ category: 'continuity', severity: 'major', summary: 'Major conflict.' }], + summary: 'Major continuity conflict.', + }, + ], + votes: [ + createVote('reader-1'), + createVote('reader-2'), + createVote('reader-3'), + createVote('reader-4'), + createVote('reader-5'), + ], + }), + ); + + const acknowledged = await acknowledgeProposalAiWarning(state.database as never, { + actorId: 'founder-1', + now: afterVotingEnds, + proposalId: 'proposal-1', + worldId: 'world-1', + }); + const finalized = await finalizeCanonDecision(state.database as never, { + actorId: 'founder-1', + now: new Date(afterVotingEnds.getTime() + 1), + proposalId: 'proposal-1', + worldId: 'world-1', + }); + + assert.equal(acknowledged.aiWarning.acknowledged, true); + assert.equal(state.auditLogs.length, 1); + assert.equal(finalized.decision?.aiWarningAcknowledged, true); + assert.equal( + (finalized.decision?.decisionPayload as { aiWarning?: { acknowledgedAt?: string } }).aiWarning + ?.acknowledgedAt, + afterVotingEnds.toISOString(), + ); + }); +}); diff --git a/apps/api/src/lib/canon-voting.ts b/apps/api/src/lib/canon-voting.ts new file mode 100644 index 0000000..cb0c465 --- /dev/null +++ b/apps/api/src/lib/canon-voting.ts @@ -0,0 +1,1261 @@ +import type { Prisma, PrismaClient } from '../generated/prisma/client.js'; +import { + HiveEventType, + ProposalDecisionOutcome, + ProposalStatus, + VoteChoice, + WorldAuditAction, +} from '../generated/prisma/enums.js'; +import { HiveBroadcastError, type HiveReliableBroadcaster } from './hive/broadcast-reliability.js'; +import { HIVELORE_CUSTOM_JSON_ID } from './hive/constants.js'; +import { + buildHiveLoreCustomJsonOperation, + parseHiveLoreCustomJsonPayload, +} from './hive/operations.js'; +import { normalizeHafOperation } from './hive/projection.js'; +import { verifyHiveLoreOperation } from './hive/verification.js'; +import type { HafClient } from './hive/haf-client.js'; +import type { HafOperationRow, NormalizedHiveOperation } from './hive/types.js'; +import { + addVotingWindow, + CANON_VOTING_RULES, + decideCanonOutcome, + hashCanonicalJson, + proposalStatusForDecision, + tallyCanonVotes, +} from './canon-voting-policy.js'; +import { roleHasWorldPermission, WORLD_PERMISSIONS } from './world-permissions.js'; + +export class CanonVotingError extends Error { + constructor( + public readonly statusCode: number, + public readonly code: string, + message: string, + ) { + super(message); + } +} + +export type CanonVotingDatabase = PrismaClient; + +const voteChoices = new Set([ + VoteChoice.APPROVE, + VoteChoice.REJECT, + VoteChoice.NEEDS_REVISION, + VoteChoice.ALTERNATE_TIMELINE, +]); + +type ProposalWithDetails = Prisma.ProposalGetPayload<{ + include: { + aiReports: true; + author: { select: { hiveUsername: true; id: true } }; + decision: true; + votes: { select: { choice: true; updatedAt: true; voterId: true } }; + world: { select: { id: true; title: true } }; + }; +}>; + +type AiWarningSnapshot = { + acknowledged: boolean; + acknowledgedAt: string | null; + acknowledgmentRequired: boolean; + category: string | null; + evidence: string | null; + severity: string | null; + summary: string | null; +}; + +function iso(value: Date | null | undefined) { + return value ? value.toISOString() : null; +} + +function assertVoteChoice(choice: string): VoteChoice { + if (!voteChoices.has(choice)) { + throw new CanonVotingError(400, 'INVALID_VOTE_CHOICE', 'Unsupported vote choice.'); + } + + return choice as VoteChoice; +} + +function readStringField(value: unknown, keys: string[]) { + if (!value || typeof value !== 'object') { + return null; + } + + const record = value as Record; + + for (const key of keys) { + const field = record[key]; + + if (typeof field === 'string' && field.trim()) { + return field.trim(); + } + } + + return null; +} + +function flattenAiFindings(value: unknown): Array> { + if (!value) { + return []; + } + + if (Array.isArray(value)) { + return value.flatMap(flattenAiFindings); + } + + if (typeof value !== 'object') { + return []; + } + + const record = value as Record; + const nested = ['findings', 'warnings', 'issues', 'conflicts', 'items'].flatMap((key) => + flattenAiFindings(record[key]), + ); + + return [record, ...nested]; +} + +function textIndicatesNoMajorWarning(source: string) { + const normalized = source.toLowerCase(); + + return [ + /\bno\s+(?:major\s+)?(?:warning|contradiction|conflict)s?\s+(?:found|detected|identified|present)?\b/, + /\bwithout\s+(?:a\s+)?(?:major\s+)?(?:warning|contradiction|conflict)\b/, + /\b(?:warning|contradiction|conflict)\s+free\b/, + ].some((pattern) => pattern.test(normalized)); +} + +function textIndicatesMajorWarning(source: string) { + const normalized = source.toLowerCase(); + + if (textIndicatesNoMajorWarning(normalized)) { + return false; + } + + return [ + /\b(?:major|critical|high)\s+(?:ai\s+)?(?:warning|contradiction|conflict|issue)\b/, + /\b(?:warning|contradiction|conflict|issue)\s+(?:severity|level)\s*[:=]\s*(?:major|critical|high)\b/, + ].some((pattern) => pattern.test(normalized)); +} + +function warningFromReport(report: { findings: unknown; summary: string | null }) { + for (const finding of flattenAiFindings(report.findings)) { + const severity = readStringField(finding, ['severity', 'level', 'risk']); + const category = readStringField(finding, ['category', 'type', 'kind']); + const summary = readStringField(finding, ['summary', 'message', 'description', 'text']); + const evidence = readStringField(finding, ['evidence', 'detail', 'details', 'excerpt']); + const source = [severity, category, summary, evidence].filter(Boolean).join(' '); + + if ( + severity && + ['major', 'critical', 'high'].includes(severity.toLowerCase()) && + !textIndicatesNoMajorWarning(source) + ) { + return { + category, + evidence, + severity, + summary: summary ?? report.summary, + }; + } + + if (textIndicatesMajorWarning(source)) { + return { + category, + evidence, + severity, + summary: summary ?? report.summary, + }; + } + } + + const source = `${report.summary ?? ''} ${JSON.stringify(report.findings ?? {})}`; + + if (!textIndicatesMajorWarning(source)) { + return null; + } + + return { + category: null, + evidence: null, + severity: null, + summary: report.summary, + }; +} + +function aiWarningSnapshot(proposal: { + aiReports: Array<{ findings: unknown; summary: string | null }>; + aiWarningAcknowledgedAt?: Date | null; +}): AiWarningSnapshot { + const warning = proposal.aiReports.map(warningFromReport).find(Boolean) ?? null; + const acknowledgedAt = iso(proposal.aiWarningAcknowledgedAt); + const acknowledgmentRequired = Boolean(warning); + + return { + acknowledged: acknowledgmentRequired && Boolean(acknowledgedAt), + acknowledgedAt, + acknowledgmentRequired, + category: warning?.category ?? null, + evidence: warning?.evidence ?? null, + severity: warning?.severity ?? null, + summary: warning?.summary ?? null, + }; +} + +function proposalAuthorExclusion(authorId: string) { + return new Set([authorId]); +} + +export function serializeDecision(decision: ProposalWithDetails['decision']) { + if (!decision) { + return null; + } + + return { + aiWarningAcknowledged: decision.aiWarningAcknowledged, + aiWarningSummary: decision.aiWarningSummary, + approvalDenominator: decision.approvalDenominator, + approvalNumerator: decision.approvalNumerator, + approvalPercentageBps: decision.approvalPercentageBps, + approvalThresholdBps: decision.approvalThresholdBps, + approveCount: decision.approveCount, + alternateTimelineCount: decision.alternateTimelineCount, + blockchainTimestamp: iso(decision.blockchainTimestamp), + blockNumber: decision.blockNumber?.toString() ?? null, + contentHash: decision.contentHash, + customJsonId: decision.customJsonId, + decidedAt: decision.decidedAt.toISOString(), + decisionPayload: decision.decisionPayload, + decisionPayloadHash: decision.decisionPayloadHash, + expectedSigner: decision.expectedSigner, + hiveEventId: decision.hiveEventId, + id: decision.id, + minimumVotes: decision.minimumVotes, + needsRevisionCount: decision.needsRevisionCount, + operationIndex: decision.operationIndex, + outcome: decision.outcome, + rejectCount: decision.rejectCount, + rulesVersion: decision.rulesVersion, + totalVotes: decision.totalVotes, + transactionId: decision.transactionId, + votingWindowHours: decision.votingWindowHours, + }; +} + +function tallyFromDecision(decision: NonNullable) { + return { + alternateTimeline: decision.alternateTimelineCount, + approvalDenominator: decision.approvalDenominator, + approvalNumerator: decision.approvalNumerator, + approvalPercentageBps: decision.approvalPercentageBps, + approve: decision.approveCount, + needsRevision: decision.needsRevisionCount, + reject: decision.rejectCount, + totalVotes: decision.totalVotes, + }; +} + +export function serializeProposalDetail( + proposal: ProposalWithDetails, + currentUserId?: string | undefined, +) { + const summary = proposal.decision + ? tallyFromDecision(proposal.decision) + : tallyCanonVotes(proposal.votes, { + excludeVoterIds: proposalAuthorExclusion(proposal.authorId), + }); + const currentVote = currentUserId + ? proposal.votes.find((vote: { voterId: string }) => vote.voterId === currentUserId) + : null; + const warning = aiWarningSnapshot(proposal); + + return { + aiWarning: warning, + author: proposal.author, + authorId: proposal.authorId, + baseCanonVersionId: proposal.baseCanonVersionId, + branchBaseLoreEntryId: proposal.branchBaseLoreEntryId, + branchLabel: proposal.branchLabel, + branchParentProposalId: proposal.branchParentProposalId, + conflictMetadata: proposal.conflictMetadata, + contentHash: proposal.contentHash, + currentUserVote: + currentVote && currentUserId !== proposal.authorId + ? { + choice: currentVote.choice, + updatedAt: currentVote.updatedAt.toISOString(), + } + : null, + decision: serializeDecision(proposal.decision), + id: proposal.id, + proposedContent: proposal.proposedContent, + proposalType: proposal.proposalType, + status: proposal.status, + submittedAt: iso(proposal.submittedAt), + summary: proposal.summary, + tally: summary, + title: proposal.title, + votingEndsAt: iso(proposal.votingEndsAt), + votingStartedAt: iso(proposal.votingStartedAt), + world: proposal.world, + worldId: proposal.worldId, + }; +} + +async function findProposal(database: CanonVotingDatabase, worldId: string, proposalId: string) { + return database.proposal.findFirst({ + include: { + aiReports: true, + author: { + select: { + hiveUsername: true, + id: true, + }, + }, + decision: true, + votes: { + select: { + choice: true, + updatedAt: true, + voterId: true, + }, + }, + world: { + select: { + id: true, + title: true, + }, + }, + }, + where: { + id: proposalId, + worldId, + }, + }); +} + +export async function getProposalDetail( + database: CanonVotingDatabase, + input: { currentUserId?: string; proposalId: string; worldId: string }, +) { + const proposal = await findProposal(database, input.worldId, input.proposalId); + + if (!proposal) { + throw new CanonVotingError(404, 'PROPOSAL_NOT_FOUND', 'Proposal not found.'); + } + + return serializeProposalDetail(proposal, input.currentUserId); +} + +export async function getVoteSummary( + database: CanonVotingDatabase, + input: { proposalId: string; worldId: string }, +) { + const proposal = await database.proposal.findFirst({ + select: { + id: true, + status: true, + votingEndsAt: true, + votingStartedAt: true, + votes: { + select: { + choice: true, + voterId: true, + }, + }, + authorId: true, + decision: true, + worldId: true, + }, + where: { + id: input.proposalId, + worldId: input.worldId, + }, + }); + + if (!proposal) { + throw new CanonVotingError(404, 'PROPOSAL_NOT_FOUND', 'Proposal not found.'); + } + + return { + rules: proposal.decision + ? { + approvalThresholdBps: proposal.decision.approvalThresholdBps, + minimumVotes: proposal.decision.minimumVotes, + payloadSchemaVersion: proposal.decision.payloadSchemaVersion, + rulesVersion: proposal.decision.rulesVersion, + votingWindowHours: proposal.decision.votingWindowHours, + } + : CANON_VOTING_RULES, + status: proposal.status, + tally: proposal.decision + ? tallyFromDecision(proposal.decision) + : tallyCanonVotes(proposal.votes, { + excludeVoterIds: proposalAuthorExclusion(proposal.authorId), + }), + votingEndsAt: iso(proposal.votingEndsAt), + votingStartedAt: iso(proposal.votingStartedAt), + }; +} + +export async function castCanonVote( + database: CanonVotingDatabase, + input: { + choice: string; + proposalId: string; + voterId: string; + worldId: string; + now?: Date; + }, +) { + const choice = assertVoteChoice(input.choice); + const now = input.now ?? new Date(); + + return database.$transaction(async (transaction: Prisma.TransactionClient) => { + const proposal = await transaction.proposal.findFirst({ + select: { + decision: { select: { id: true } }, + id: true, + authorId: true, + status: true, + votingEndsAt: true, + worldId: true, + }, + where: { + id: input.proposalId, + worldId: input.worldId, + }, + }); + + if (!proposal) { + throw new CanonVotingError(404, 'PROPOSAL_NOT_FOUND', 'Proposal not found.'); + } + + if (proposal.status !== ProposalStatus.VOTING || proposal.decision) { + throw new CanonVotingError(409, 'PROPOSAL_NOT_VOTING', 'Proposal is not open for voting.'); + } + + if (proposal.authorId === input.voterId) { + throw new CanonVotingError( + 403, + 'PROPOSAL_AUTHOR_CANNOT_VOTE', + 'Proposal authors cannot vote on their own canon proposals.', + ); + } + + if (!proposal.votingEndsAt || now.getTime() >= proposal.votingEndsAt.getTime()) { + throw new CanonVotingError(409, 'VOTING_CLOSED', 'Voting is closed for this proposal.'); + } + + const vote = await transaction.appVote.upsert({ + create: { + choice, + proposalId: input.proposalId, + voterId: input.voterId, + }, + update: { + choice, + }, + where: { + proposalId_voterId: { + proposalId: input.proposalId, + voterId: input.voterId, + }, + }, + }); + + return { + vote: { + choice: vote.choice, + createdAt: vote.createdAt.toISOString(), + id: vote.id, + proposalId: vote.proposalId, + updatedAt: vote.updatedAt.toISOString(), + voterId: vote.voterId, + }, + }; + }); +} + +export async function acknowledgeProposalAiWarning( + database: CanonVotingDatabase, + input: { actorId: string; proposalId: string; worldId: string; now?: Date }, +) { + const now = input.now ?? new Date(); + + return database.$transaction(async (transaction: Prisma.TransactionClient) => { + const proposal = await transaction.proposal.findFirst({ + include: { + aiReports: true, + }, + where: { + id: input.proposalId, + worldId: input.worldId, + }, + }); + + if (!proposal) { + throw new CanonVotingError(404, 'PROPOSAL_NOT_FOUND', 'Proposal not found.'); + } + + const warning = aiWarningSnapshot(proposal); + + if (!warning.acknowledgmentRequired) { + throw new CanonVotingError( + 409, + 'AI_WARNING_NOT_REQUIRED', + 'This proposal does not have a major AI warning to acknowledge.', + ); + } + + const updated = await transaction.proposal.update({ + data: { + aiWarningAcknowledgedAt: proposal.aiWarningAcknowledgedAt ?? now, + }, + where: { + id: proposal.id, + }, + }); + + const snapshot = aiWarningSnapshot({ + aiReports: proposal.aiReports, + aiWarningAcknowledgedAt: updated.aiWarningAcknowledgedAt, + }); + + if (!proposal.aiWarningAcknowledgedAt) { + await transaction.worldAuditLog.create({ + data: { + action: WorldAuditAction.AI_WARNING_RESOLVED, + actorId: input.actorId, + metadata: { + acknowledgedAt: snapshot.acknowledgedAt, + category: snapshot.category, + evidence: snapshot.evidence, + proposalId: proposal.id, + severity: snapshot.severity, + summary: snapshot.summary, + }, + targetId: proposal.id, + targetType: 'PROPOSAL', + worldId: input.worldId, + }, + }); + } + + return { + aiWarning: snapshot, + idempotent: Boolean(proposal.aiWarningAcknowledgedAt), + }; + }); +} + +function buildDecisionPayload(input: { + aiWarning: AiWarningSnapshot; + outcome: ProposalDecisionOutcome; + proposal: { + baseCanonVersionId: string | null; + branchBaseLoreEntryId: string | null; + branchLabel: string | null; + branchParentProposalId: string | null; + conflictMetadata: unknown; + contentHash: string | null; + decidedAt: Date; + id: string; + proposedContent: unknown; + staleBaseAtDecision: boolean; + votingEndsAt: Date; + votingStartedAt: Date; + worldId: string; + }; + tally: ReturnType; +}) { + const contentHash = + input.proposal.contentHash ?? hashCanonicalJson(input.proposal.proposedContent); + + return { + aiWarning: { + acknowledged: input.aiWarning.acknowledged, + acknowledgedAt: input.aiWarning.acknowledgedAt, + acknowledgmentRequired: input.aiWarning.acknowledgmentRequired, + category: input.aiWarning.category, + evidence: input.aiWarning.evidence, + severity: input.aiWarning.severity, + summary: input.aiWarning.summary, + }, + approval: { + denominator: input.tally.approvalDenominator, + numerator: input.tally.approvalNumerator, + percentageBps: input.tally.approvalPercentageBps, + }, + branch: { + baseCanonVersionId: input.proposal.baseCanonVersionId, + baseLoreEntryId: input.proposal.branchBaseLoreEntryId, + conflictMetadata: input.proposal.conflictMetadata ?? null, + label: input.proposal.branchLabel, + parentProposalId: input.proposal.branchParentProposalId, + staleBaseAtDecision: input.proposal.staleBaseAtDecision, + }, + contentHash, + counts: { + alternateTimeline: input.tally.alternateTimeline, + approve: input.tally.approve, + needsRevision: input.tally.needsRevision, + reject: input.tally.reject, + total: input.tally.totalVotes, + }, + eventType: 'canon_decision', + outcome: input.outcome, + proposalId: input.proposal.id, + rules: CANON_VOTING_RULES, + voting: { + decidedAt: input.proposal.decidedAt.toISOString(), + votingEndsAt: input.proposal.votingEndsAt.toISOString(), + votingStartedAt: input.proposal.votingStartedAt.toISOString(), + }, + worldId: input.proposal.worldId, + }; +} + +export async function finalizeCanonDecision( + database: CanonVotingDatabase, + input: { actorId: string; proposalId: string; worldId: string; now?: Date }, +) { + const now = input.now ?? new Date(); + + return database.$transaction(async (transaction: Prisma.TransactionClient) => { + const existing = await transaction.proposalDecision.findUnique({ + where: { + proposalId: input.proposalId, + }, + }); + + if (existing) { + return { + decision: serializeDecision(existing), + idempotent: true, + }; + } + + const proposal = await transaction.proposal.findFirst({ + include: { + aiReports: true, + author: { + select: { + id: true, + }, + }, + votes: { + select: { + choice: true, + voterId: true, + }, + }, + }, + where: { + id: input.proposalId, + worldId: input.worldId, + }, + }); + + if (!proposal) { + throw new CanonVotingError(404, 'PROPOSAL_NOT_FOUND', 'Proposal not found.'); + } + + if (proposal.status !== ProposalStatus.VOTING) { + throw new CanonVotingError( + 409, + 'PROPOSAL_NOT_VOTING', + 'Proposal is not open for finalization.', + ); + } + + if (!proposal.votingStartedAt || !proposal.votingEndsAt) { + throw new CanonVotingError(409, 'VOTING_WINDOW_MISSING', 'Proposal has no voting window.'); + } + + if (now.getTime() < proposal.votingEndsAt.getTime()) { + throw new CanonVotingError(409, 'VOTING_STILL_OPEN', 'Voting is still open.'); + } + + const currentBible = await transaction.worldBibleVersion.findFirst({ + orderBy: { + versionNumber: 'desc', + }, + select: { + id: true, + }, + where: { + worldId: input.worldId, + }, + }); + + const staleBaseAtDecision = Boolean( + proposal.baseCanonVersionId && + currentBible?.id && + proposal.baseCanonVersionId !== currentBible.id, + ); + const tally = tallyCanonVotes(proposal.votes, { + excludeVoterIds: proposalAuthorExclusion(proposal.authorId), + }); + const outcome = decideCanonOutcome({ + now, + staleBaseAtDecision, + tally, + votingEndsAt: proposal.votingEndsAt, + }); + + if (!outcome) { + throw new CanonVotingError(409, 'VOTING_STILL_OPEN', 'Voting is still open.'); + } + + const warning = aiWarningSnapshot(proposal); + + if (warning.acknowledgmentRequired && !warning.acknowledged) { + throw new CanonVotingError( + 409, + 'AI_WARNING_ACKNOWLEDGMENT_REQUIRED', + 'Major AI warnings must be explicitly acknowledged before finalization.', + ); + } + + const decidedAt = now; + const contentHash = proposal.contentHash ?? hashCanonicalJson(proposal.proposedContent); + const payload = buildDecisionPayload({ + aiWarning: warning, + outcome, + proposal: { + ...proposal, + contentHash, + decidedAt, + staleBaseAtDecision, + votingEndsAt: proposal.votingEndsAt, + votingStartedAt: proposal.votingStartedAt, + }, + tally, + }); + const payloadHash = hashCanonicalJson(payload); + const decision = await transaction.proposalDecision.create({ + data: { + aiWarningAcknowledged: warning.acknowledged, + aiWarningSummary: warning.summary, + alternateTimelineCount: tally.alternateTimeline, + approvalDenominator: tally.approvalDenominator, + approvalNumerator: tally.approvalNumerator, + approvalPercentageBps: tally.approvalPercentageBps, + approvalThresholdBps: CANON_VOTING_RULES.approvalThresholdBps, + approveCount: tally.approve, + baseCanonVersionId: proposal.baseCanonVersionId, + branchBaseLoreEntryId: proposal.branchBaseLoreEntryId, + branchLabel: proposal.branchLabel, + branchParentProposalId: proposal.branchParentProposalId, + conflictMetadata: proposal.conflictMetadata as Prisma.InputJsonValue, + contentHash, + decidedAt, + decisionPayload: payload as Prisma.InputJsonValue, + decisionPayloadHash: payloadHash, + minimumVotes: CANON_VOTING_RULES.minimumVotes, + needsRevisionCount: tally.needsRevision, + outcome, + payloadSchemaVersion: CANON_VOTING_RULES.payloadSchemaVersion, + proposalId: proposal.id, + rejectCount: tally.reject, + rulesVersion: CANON_VOTING_RULES.rulesVersion, + staleBaseAtDecision, + totalVotes: tally.totalVotes, + votingWindowHours: CANON_VOTING_RULES.votingWindowHours, + }, + }); + + await transaction.proposal.update({ + data: { + ...(outcome === ProposalDecisionOutcome.APPROVED_FOR_PUBLICATION + ? { approvedAt: decidedAt } + : { rejectedAt: decidedAt }), + contentHash, + decidedAt, + staleBaseAtDecision, + status: proposalStatusForDecision(outcome), + }, + where: { + id: proposal.id, + }, + }); + + await transaction.worldAuditLog.create({ + data: { + action: WorldAuditAction.CANON_DECISION_FINALIZED, + actorId: input.actorId, + metadata: { + decisionId: decision.id, + outcome, + payloadHash, + tally, + }, + targetId: proposal.id, + targetType: 'PROPOSAL', + worldId: input.worldId, + }, + }); + + return { + decision: serializeDecision(decision), + idempotent: false, + }; + }); +} + +export async function createCanonTransactionOperation( + database: CanonVotingDatabase, + input: { proposalId: string; signerId: string; worldId: string }, +) { + const proposal = await database.proposal.findFirst({ + include: { + author: { + select: { + hiveUsername: true, + id: true, + }, + }, + decision: true, + }, + where: { + id: input.proposalId, + worldId: input.worldId, + }, + }); + + if (!proposal?.decision) { + throw new CanonVotingError( + 409, + 'DECISION_NOT_FINALIZED', + 'Proposal decision is not finalized.', + ); + } + + if (proposal.decision.outcome !== ProposalDecisionOutcome.APPROVED_FOR_PUBLICATION) { + throw new CanonVotingError( + 409, + 'DECISION_NOT_APPROVED', + 'Only approved decisions go on-chain.', + ); + } + + if (proposal.decision.transactionId || proposal.decision.operationIndex !== null) { + throw new CanonVotingError( + 409, + 'DECISION_ALREADY_CONFIRMED', + 'Proposal decision is already linked to a Hive operation.', + ); + } + + if (proposal.authorId !== input.signerId) { + throw new CanonVotingError( + 403, + 'SIGNER_NOT_AUTHOR', + 'Only the proposal author can sign the canon decision.', + ); + } + + await assertCanSubmitCanonTransaction(database, { + actorId: input.signerId, + proposalAuthorId: proposal.authorId, + worldId: input.worldId, + }); + + if ( + proposal.decision.transactionId || + proposal.decision.operationIndex !== null || + proposal.decision.hiveEventId + ) { + throw new CanonVotingError( + 409, + 'DECISION_ALREADY_CONFIRMED', + 'Proposal decision is already linked to a Hive operation.', + ); + } + + const signer = proposal.author.hiveUsername.toLowerCase(); + const operation = buildHiveLoreCustomJsonOperation({ + action: 'canon_approval', + entityId: proposal.decision.id, + entityType: 'CANON_DECISION', + payload: proposal.decision.decisionPayload as Record, + proposalId: proposal.id, + signer, + worldId: proposal.worldId, + }); + + await database.proposalDecision.update({ + data: { + customJsonId: HIVELORE_CUSTOM_JSON_ID, + expectedSigner: signer, + }, + where: { + id: proposal.decision.id, + }, + }); + + return { + customJsonId: HIVELORE_CUSTOM_JSON_ID, + decisionId: proposal.decision.id, + operation, + signer, + }; +} + +function readHafTransactionId(row: HafOperationRow) { + const value = row.transaction_id ?? row.transactionId ?? row.trx_id; + + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function readHafOperationIndex(row: HafOperationRow) { + const value = row.operation_id ?? row.operationIndex ?? row.op_in_trx ?? row.op_pos; + const numeric = typeof value === 'string' ? Number(value) : value; + + return typeof numeric === 'number' && Number.isInteger(numeric) ? numeric : null; +} + +async function assertCanSubmitCanonTransaction( + database: CanonVotingDatabase | Prisma.TransactionClient, + input: { actorId: string; proposalAuthorId: string; worldId: string }, +) { + if (input.proposalAuthorId !== input.actorId) { + throw new CanonVotingError( + 403, + 'CONFIRMER_NOT_AUTHOR', + 'Only the proposal author can confirm the canon decision transaction.', + ); + } + + const membership = await database.worldMembership.findUnique({ + select: { + role: true, + }, + where: { + revokedAt: null, + worldId_userId: { + userId: input.actorId, + worldId: input.worldId, + }, + }, + }); + + if (!membership || !roleHasWorldPermission(membership.role, WORLD_PERMISSIONS.SUBMIT_PROPOSAL)) { + throw new CanonVotingError( + 403, + 'INSUFFICIENT_WORLD_PERMISSIONS', + 'Insufficient world permissions.', + ); + } +} + +async function findConfirmedOperation(input: { + blockNumber?: number | undefined; + expectedOperation: ReturnType; + expectedSigner: string; + hafClient: HafClient; + hiveBroadcaster?: HiveReliableBroadcaster | undefined; + operationIndex?: number | undefined; + transactionId: string; +}): Promise { + if (input.hiveBroadcaster) { + try { + return await input.hiveBroadcaster.confirmTransactionOperation({ + blockNumberHint: input.blockNumber, + expectedOperation: input.expectedOperation, + expectedSigner: input.expectedSigner, + operationIndex: input.operationIndex, + transactionId: input.transactionId, + }); + } catch (error) { + if (error instanceof HiveBroadcastError && error.code === 'BROADCAST_CONFIRMATION_TIMEOUT') { + return null; + } + + throw error; + } + } + + if (!input.blockNumber || input.operationIndex === undefined) { + throw new CanonVotingError( + 400, + 'CONFIRMATION_LOOKUP_UNAVAILABLE', + 'A confirmation service or block and operation hints are required.', + ); + } + + const page = await input.hafClient.searchBlocks({ + fromBlock: input.blockNumber, + toBlock: input.blockNumber, + }); + + for (const row of page.operations) { + if (readHafTransactionId(row) !== input.transactionId) { + continue; + } + + if (readHafOperationIndex(row) !== input.operationIndex) { + continue; + } + + try { + const operation = normalizeHafOperation(row); + return operation; + } catch (error) { + throw new CanonVotingError( + 400, + 'HIVE_OPERATION_INVALID', + error instanceof Error ? error.message : 'Invalid Hive operation.', + ); + } + } + + return null; +} + +export async function confirmCanonTransaction( + database: CanonVotingDatabase, + input: { + actorId: string; + blockNumber?: number | undefined; + hafClient: HafClient; + hiveBroadcaster?: HiveReliableBroadcaster | undefined; + operationIndex?: number | undefined; + proposalId: string; + transactionId: string; + worldId: string; + }, +) { + const proposal = await database.proposal.findFirst({ + include: { + author: { + select: { + hiveUsername: true, + id: true, + }, + }, + decision: true, + }, + where: { + id: input.proposalId, + worldId: input.worldId, + }, + }); + + if (!proposal?.decision) { + throw new CanonVotingError( + 409, + 'DECISION_NOT_FINALIZED', + 'Proposal decision is not finalized.', + ); + } + + const currentDecision = proposal.decision; + + await assertCanSubmitCanonTransaction(database, { + actorId: input.actorId, + proposalAuthorId: proposal.author.id, + worldId: input.worldId, + }); + + if ( + currentDecision.transactionId === input.transactionId && + (input.operationIndex === undefined || currentDecision.operationIndex === input.operationIndex) + ) { + return { + decision: serializeDecision(currentDecision), + idempotent: true, + }; + } + + if (currentDecision.transactionId || currentDecision.operationIndex !== null) { + throw new CanonVotingError( + 409, + 'DECISION_ALREADY_CONFIRMED', + 'Proposal decision is already linked to a different Hive operation.', + ); + } + + const signer = (currentDecision.expectedSigner ?? proposal.author.hiveUsername).toLowerCase(); + const expectedOperation = buildHiveLoreCustomJsonOperation({ + action: 'canon_approval', + entityId: currentDecision.id, + entityType: 'CANON_DECISION', + payload: currentDecision.decisionPayload as Record, + proposalId: input.proposalId, + signer, + worldId: input.worldId, + }); + const operation = await findConfirmedOperation({ + blockNumber: input.blockNumber, + expectedOperation, + expectedSigner: signer, + hafClient: input.hafClient, + hiveBroadcaster: input.hiveBroadcaster, + operationIndex: input.operationIndex, + transactionId: input.transactionId, + }); + + if (!operation) { + throw new CanonVotingError( + 404, + 'HIVE_OPERATION_NOT_FOUND', + 'Hive operation was not confirmed.', + ); + } + + const verification = verifyHiveLoreOperation({ + expectedSigner: signer, + operation: operation.operation, + }); + + if (!verification.ok) { + throw new CanonVotingError( + 400, + 'HIVE_OPERATION_INVALID', + verification.reason ?? 'Invalid Hive operation.', + ); + } + + const customJsonOperation = operation.operation.custom_json_operation; + + if ( + !customJsonOperation || + customJsonOperation.required_auths.length > 0 || + !customJsonOperation.required_posting_auths.includes(signer) + ) { + throw new CanonVotingError( + 400, + 'HIVE_OPERATION_INVALID_AUTHORITY', + 'Canon decisions must be signed with posting authority by the expected signer.', + ); + } + + const payload = parseHiveLoreCustomJsonPayload(operation.operation); + + if ( + !payload || + payload.action !== 'canon_approval' || + payload.entityType !== 'CANON_DECISION' || + payload.entityId !== currentDecision.id || + payload.worldId !== input.worldId || + payload.proposalId !== input.proposalId || + hashCanonicalJson(payload.payload) !== currentDecision.decisionPayloadHash + ) { + throw new CanonVotingError( + 400, + 'HIVE_OPERATION_MISMATCH', + 'Hive operation does not match the frozen decision.', + ); + } + + return database.$transaction(async (transaction: Prisma.TransactionClient) => { + const confirmationClaim = await transaction.proposalDecision.updateMany({ + data: { + blockchainTimestamp: operation.blockchainTimestamp, + blockNumber: operation.blockNumber, + customJsonId: HIVELORE_CUSTOM_JSON_ID, + expectedSigner: signer, + operationIndex: operation.operationIndex, + transactionId: operation.transactionId, + }, + where: { + hiveEventId: null, + id: currentDecision.id, + operationIndex: null, + transactionId: null, + }, + }); + + if (confirmationClaim.count !== 1) { + const confirmedDecision = await transaction.proposalDecision.findUnique({ + where: { + id: currentDecision.id, + }, + }); + + if ( + confirmedDecision?.transactionId === operation.transactionId && + confirmedDecision.operationIndex === operation.operationIndex + ) { + return { + decision: serializeDecision(confirmedDecision), + idempotent: true, + }; + } + + throw new CanonVotingError( + 409, + 'DECISION_ALREADY_CONFIRMED', + 'Proposal decision is already linked to a different Hive operation.', + ); + } + + const hiveEvent = await transaction.hiveEvent.upsert({ + create: { + blockNumber: operation.blockNumber, + blockchainTimestamp: operation.blockchainTimestamp, + eventType: HiveEventType.CUSTOM_JSON, + operationIndex: operation.operationIndex, + payload: operation.operation as Prisma.InputJsonValue, + transactionId: operation.transactionId, + }, + update: { + blockNumber: operation.blockNumber, + blockchainTimestamp: operation.blockchainTimestamp, + eventType: HiveEventType.CUSTOM_JSON, + payload: operation.operation as Prisma.InputJsonValue, + }, + where: { + transactionId_operationIndex: { + operationIndex: operation.operationIndex, + transactionId: operation.transactionId, + }, + }, + }); + + const confirmedDecision = await transaction.proposalDecision.update({ + data: { + hiveEventId: hiveEvent.id, + }, + where: { + id: currentDecision.id, + }, + }); + + await transaction.worldAuditLog.create({ + data: { + action: WorldAuditAction.CANON_DECISION_CONFIRMED, + actorId: input.actorId, + metadata: { + blockNumber: operation.blockNumber.toString(), + decisionId: currentDecision.id, + hiveEventId: hiveEvent.id, + operationIndex: operation.operationIndex, + transactionId: operation.transactionId, + }, + targetId: currentDecision.id, + targetType: 'PROPOSAL_DECISION', + worldId: input.worldId, + }, + }); + + return { + decision: serializeDecision(confirmedDecision), + idempotent: false, + }; + }); +} + +export function prepareSubmittedProposalVotingFields(input: { + proposedContent: unknown; + submittedAt: Date; +}) { + return { + contentHash: hashCanonicalJson(input.proposedContent), + votingEndsAt: addVotingWindow(input.submittedAt), + votingStartedAt: input.submittedAt, + }; +} diff --git a/apps/api/src/lib/contributions.ts b/apps/api/src/lib/contributions.ts index 675109b..e502159 100644 --- a/apps/api/src/lib/contributions.ts +++ b/apps/api/src/lib/contributions.ts @@ -6,6 +6,7 @@ import { ProposalType, WorldAuditAction, } from '../generated/prisma/enums.js'; +import { prepareSubmittedProposalVotingFields } from './canon-voting.js'; const STRUCTURED_DOCUMENT_MAX_BYTES = 100 * 1024; @@ -24,6 +25,7 @@ export type ContributionDatabase = Pick< | 'contributionDraft' | 'loreEntry' | 'proposal' + | 'refreshSession' | 'world' | 'worldAuditLog' | 'worldMembership' @@ -294,34 +296,36 @@ export async function createContribution( await assertWorldExists(database, input.worldId); await assertTargetLoreEntry(database, input.worldId, input.targetLoreEntryId); - const contribution = await database.$transaction(async (transaction) => { - const created = await transaction.contributionDraft.create({ - data: { - authorId: input.authorId, - content, - kind: input.kind, - summary, - targetLoreEntryId: input.targetLoreEntryId || null, - title, - worldId: input.worldId, - }, - include: contributionInclude, - }); + const contribution = await database.$transaction( + async (transaction: Prisma.TransactionClient) => { + const created = await transaction.contributionDraft.create({ + data: { + authorId: input.authorId, + content, + kind: input.kind, + summary, + targetLoreEntryId: input.targetLoreEntryId || null, + title, + worldId: input.worldId, + }, + include: contributionInclude, + }); - await createContributionAuditLog(transaction, { - action: WorldAuditAction.CONTRIBUTION_CREATED, - actorId: input.authorId, - metadata: { - kind: input.kind, - targetLoreEntryId: input.targetLoreEntryId ?? null, - }, - targetId: created.id, - targetType: 'CONTRIBUTION_DRAFT', - worldId: input.worldId, - }); + await createContributionAuditLog(transaction, { + action: WorldAuditAction.CONTRIBUTION_CREATED, + actorId: input.authorId, + metadata: { + kind: input.kind, + targetLoreEntryId: input.targetLoreEntryId ?? null, + }, + targetId: created.id, + targetType: 'CONTRIBUTION_DRAFT', + worldId: input.worldId, + }); - return created; - }); + return created; + }, + ); return serializeContribution(contribution); } @@ -484,70 +488,72 @@ export async function updateContribution( data.updatedAt = new Date(); } - const contribution = await database.$transaction(async (transaction) => { - const updated = await transaction.contributionDraft.updateMany({ - data, - where: { - authorId: input.authorId, - id: input.contributionId, - status: ContributionStatus.DRAFT, - worldId: input.worldId, - }, - }); - - if (updated.count !== 1) { - await resolveMissingOrLockedContribution(transaction, { - ...input, - lockedMessage: 'Submitted contributions cannot be edited.', + const contribution = await database.$transaction( + async (transaction: Prisma.TransactionClient) => { + const updated = await transaction.contributionDraft.updateMany({ + data, + where: { + authorId: input.authorId, + id: input.contributionId, + status: ContributionStatus.DRAFT, + worldId: input.worldId, + }, }); - } - if (targetLoreEntryId !== undefined) { - await transaction.contributionDraft.update({ - data: { - targetLoreEntry: - targetLoreEntryId === null - ? { - disconnect: true, - } - : { - connect: { - id: targetLoreEntryId, + if (updated.count !== 1) { + await resolveMissingOrLockedContribution(transaction, { + ...input, + lockedMessage: 'Submitted contributions cannot be edited.', + }); + } + + if (targetLoreEntryId !== undefined) { + await transaction.contributionDraft.update({ + data: { + targetLoreEntry: + targetLoreEntryId === null + ? { + disconnect: true, + } + : { + connect: { + id: targetLoreEntryId, + }, }, - }, - }, + }, + where: { + id: input.contributionId, + }, + }); + } + + const current = await transaction.contributionDraft.findFirst({ + include: contributionInclude, where: { + authorId: input.authorId, id: input.contributionId, + worldId: input.worldId, }, }); - } - const current = await transaction.contributionDraft.findFirst({ - include: contributionInclude, - where: { - authorId: input.authorId, - id: input.contributionId, - worldId: input.worldId, - }, - }); - - if (!current) { - throw new ContributionError(404, 'Contribution not found.'); - } + if (!current) { + throw new ContributionError(404, 'Contribution not found.'); + } - await createContributionAuditLog(transaction, { - action: WorldAuditAction.CONTRIBUTION_UPDATED, - actorId: input.authorId, - metadata: { - changedFields: changedFields.sort(), - }, - targetId: current.id, - targetType: 'CONTRIBUTION_DRAFT', - worldId: input.worldId, - }); + await createContributionAuditLog(transaction, { + action: WorldAuditAction.CONTRIBUTION_UPDATED, + actorId: input.authorId, + metadata: { + changedFields: changedFields.sort(), + }, + targetId: current.id, + targetType: 'CONTRIBUTION_DRAFT', + worldId: input.worldId, + }); - return current; - }); + return current; + }, + ); return serializeContribution(contribution); } @@ -560,7 +566,7 @@ export async function deleteContribution( authorId: string; }, ) { - await database.$transaction(async (transaction) => { + await database.$transaction(async (transaction: Prisma.TransactionClient) => { const existing = await transaction.contributionDraft.findFirst({ where: { authorId: input.authorId, @@ -615,7 +621,7 @@ export async function submitContribution( authorId: string; }, ) { - const result = await database.$transaction(async (transaction) => { + const result = await database.$transaction(async (transaction: Prisma.TransactionClient) => { const existing = await transaction.contributionDraft.findFirst({ include: contributionInclude, where: { @@ -652,6 +658,21 @@ export async function submitContribution( await assertTargetLoreEntry(transaction, input.worldId, existing.targetLoreEntryId); const submittedAt = new Date(); + const votingFields = prepareSubmittedProposalVotingFields({ + proposedContent: structuredContent, + submittedAt, + }); + const currentBible = await transaction.worldBibleVersion.findFirst({ + orderBy: { + versionNumber: 'desc', + }, + select: { + id: true, + }, + where: { + worldId: input.worldId, + }, + }); const transition = await transaction.contributionDraft.updateMany({ data: { status: ContributionStatus.SUBMITTED, @@ -691,17 +712,21 @@ export async function submitContribution( const proposal = await transaction.proposal.create({ data: { authorId: input.authorId, + baseCanonVersionId: currentBible?.id ?? null, contributionKind: existing.kind, + contentHash: votingFields.contentHash, proposedContent: structuredContent, proposalType: proposalTypeForContribution( existing.kind, Boolean(existing.targetLoreEntryId), ), - status: ProposalStatus.SUBMITTED, + status: ProposalStatus.VOTING, submittedAt, summary: existing.summary ?? '', targetLoreEntryId: existing.targetLoreEntryId, title: existing.title, + votingEndsAt: votingFields.votingEndsAt, + votingStartedAt: votingFields.votingStartedAt, worldId: input.worldId, }, }); diff --git a/apps/api/src/lib/hive/broadcast-reliability.test.ts b/apps/api/src/lib/hive/broadcast-reliability.test.ts new file mode 100644 index 0000000..df734ed --- /dev/null +++ b/apps/api/src/lib/hive/broadcast-reliability.test.ts @@ -0,0 +1,723 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import type { ApiTransaction } from '@hiveio/wax'; + +import { buildHiveLoreCustomJsonOperation } from './operations.js'; +import { + classifyHiveBroadcastError, + delayForAttempt, + DefaultHiveBroadcastTransport, + findAndVerifyOperation, + HiveBroadcastError, + HiveNodePool, + HiveReliableBroadcaster, +} from './broadcast-reliability.js'; +import { + buildHiveNetworkConfig, + DEFAULT_HIVE_RETRY_CONFIG, + parseNodeList, +} from './network-config.js'; +import type { HafOperationRow } from './types.js'; + +const network = buildHiveNetworkConfig({ + customJsonId: 'hivelore', + mainnetRpcNodes: 'https://node-a.test,https://node-b.test', + nodeEnv: 'test', +}); + +const transaction: ApiTransaction = { + expiration: '2026-08-10T12:05:00', + extensions: [], + operations: [], + ref_block_num: 1, + ref_block_prefix: 2, + signatures: ['public-signature'], +}; + +const operation = buildHiveLoreCustomJsonOperation({ + action: 'canon_approval', + entityId: 'decision-1', + entityType: 'CANON_DECISION', + payload: { + contentHash: 'hash-1', + }, + proposalId: 'proposal-1', + signer: 'mira-vale.dev', + worldId: 'world-1', +}); + +function row(transactionId = 'tx-1'): HafOperationRow { + return { + block_num: 101, + operation, + operation_id: 0, + timestamp: '2026-08-10T12:01:00.000Z', + transaction_id: transactionId, + }; +} + +function fakeClock(random = 0.5) { + let now = 0; + + return { + advance(ms: number) { + now += ms; + }, + now: () => now, + random: () => random, + async sleep(ms: number) { + now += ms; + }, + }; +} + +describe('Hive broadcast reliability', () => { + test('transaction lookup uses the confirmed block header timestamp instead of expiration', async () => { + const customJson = operation.custom_json_operation; + assert.ok(customJson); + const calls: Array<{ method: string; params: unknown[] }> = []; + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async (_input: string | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { method: string; params: unknown[] }; + calls.push({ method: body.method, params: body.params }); + + if (body.method === 'condenser_api.get_transaction') { + return Response.json({ + result: { + block_num: 101, + expiration: '2026-08-10T12:11:00', + operations: [['custom_json', customJson]], + trx_id: 'tx-1', + }, + }); + } + + if (body.method === 'condenser_api.get_block_header') { + return Response.json({ + result: { + previous: '00000000', + timestamp: '2026-08-10T12:01:00', + }, + }); + } + + return Response.json({ result: null }); + }) as typeof fetch; + + try { + const rows = await new DefaultHiveBroadcastTransport(network).getTransaction({ + transactionId: 'tx-1', + }); + const confirmed = findAndVerifyOperation(rows ?? [], { + expectedOperation: operation, + expectedSigner: 'mira-vale.dev', + transactionId: 'tx-1', + }); + + assert.equal(confirmed?.blockchainTimestamp.toISOString(), '2026-08-10T12:01:00.000Z'); + assert.notEqual(confirmed?.blockchainTimestamp.toISOString(), '2026-08-10T12:11:00.000Z'); + assert.deepEqual(calls, [ + { method: 'condenser_api.get_transaction', params: ['tx-1'] }, + { method: 'condenser_api.get_block_header', params: [101] }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test('transaction lookup fails closed when block metadata is missing or invalid', async () => { + const customJson = operation.custom_json_operation; + assert.ok(customJson); + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async (_input: string | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { method: string }; + + if (body.method === 'condenser_api.get_transaction') { + return Response.json({ + result: { + block_num: 101, + operations: [['custom_json', customJson]], + trx_id: 'tx-1', + }, + }); + } + + return Response.json({ + result: { + timestamp: 'not-a-date', + }, + }); + }) as typeof fetch; + + try { + await assert.rejects( + () => new DefaultHiveBroadcastTransport(network).getTransaction({ transactionId: 'tx-1' }), + (error: unknown) => + error instanceof HiveBroadcastError && + error.code === 'BROADCAST_REJECTED' && + error.diagnostics.reason === 'missing_or_invalid_block_timestamp', + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test('transaction lookup rejects mismatched transaction IDs with diagnostics', async () => { + const customJson = operation.custom_json_operation; + assert.ok(customJson); + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async (_input: string | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { method: string }; + + if (body.method === 'condenser_api.get_transaction') { + return Response.json({ + result: { + block_num: 101, + operations: [['custom_json', customJson]], + trx_id: 'tx-other', + }, + }); + } + + return Response.json({ + result: { + timestamp: '2026-08-10T12:01:00', + }, + }); + }) as typeof fetch; + + try { + await assert.rejects( + () => new DefaultHiveBroadcastTransport(network).getTransaction({ transactionId: 'tx-1' }), + (error: unknown) => + error instanceof HiveBroadcastError && + error.code === 'BROADCAST_REJECTED' && + error.diagnostics.reason === 'transaction_id_mismatch', + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test('retries a transient broadcast failure on the next node and confirms through HAF', async () => { + const calls: string[] = []; + const clock = fakeClock(); + const broadcaster = new HiveReliableBroadcaster( + network, + { + async broadcast(nodeUrl) { + calls.push(nodeUrl); + + if (calls.length === 1) { + throw Object.assign(new Error('HTTP 503'), { status: 503 }); + } + }, + async getHeadBlock() { + return 101; + }, + async searchBlocks() { + return calls.length < 2 ? [] : [row()]; + }, + }, + { + confirmationPollIntervalMs: 1, + confirmationTimeoutMs: 10, + initialDelayMs: 10, + maxDelayMs: 10, + }, + clock, + ); + + const result = await broadcaster.broadcastSignedTransaction({ + expectedOperation: operation, + expectedSigner: 'mira-vale.dev', + transaction, + transactionId: 'tx-1', + }); + + assert.deepEqual(calls, ['https://node-a.test', 'https://node-b.test']); + assert.equal(result.transactionId, 'tx-1'); + assert.equal(result.blockNumber, 101); + assert.equal(result.attempts, 2); + }); + + test('fails fast for permanent transaction errors without rotating every node', async () => { + const calls: string[] = []; + const broadcaster = new HiveReliableBroadcaster( + network, + { + async broadcast(nodeUrl) { + calls.push(nodeUrl); + throw new Error('missing required signature'); + }, + async getHeadBlock() { + return 0; + }, + async searchBlocks() { + return []; + }, + }, + {}, + fakeClock(), + ); + + await assert.rejects( + broadcaster.broadcastSignedTransaction({ + transaction, + transactionId: 'tx-1', + }), + (error: unknown) => + error instanceof HiveBroadcastError && error.code === 'PERMANENT_TRANSACTION_ERROR', + ); + assert.deepEqual(calls, ['https://node-a.test']); + }); + + test('ambiguous timeout enters confirmation before retrying the same signed transaction', async () => { + let broadcasts = 0; + const broadcaster = new HiveReliableBroadcaster( + network, + { + async broadcast() { + broadcasts += 1; + throw new Error('request timeout'); + }, + async getHeadBlock() { + return 101; + }, + async searchBlocks() { + return [row()]; + }, + }, + { + confirmationPollIntervalMs: 1, + confirmationTimeoutMs: 10, + }, + fakeClock(), + ); + + const result = await broadcaster.broadcastSignedTransaction({ + expectedOperation: operation, + expectedSigner: 'mira-vale.dev', + transaction, + transactionId: 'tx-1', + }); + + assert.equal(broadcasts, 1); + assert.equal(result.transactionId, 'tx-1'); + }); + + test('confirmation read-back rotates nodes after transient provider failures', async () => { + const readNodes: string[] = []; + const broadcaster = new HiveReliableBroadcaster( + network, + { + async broadcast() {}, + async getHeadBlock(nodeUrl) { + readNodes.push(nodeUrl ?? 'none'); + + if (readNodes.length === 1) { + throw Object.assign(new Error('HTTP 503'), { status: 503 }); + } + + return 101; + }, + async searchBlocks() { + return [row()]; + }, + }, + { + confirmationPollIntervalMs: 1, + confirmationTimeoutMs: 10, + maxConsecutiveNodeFailures: 1, + }, + fakeClock(), + ); + + const result = await broadcaster.confirmTransaction({ + expectedOperation: operation, + expectedSigner: 'mira-vale.dev', + transactionId: 'tx-1', + }); + + assert.deepEqual(readNodes, ['https://node-a.test', 'https://node-b.test']); + assert.equal(result.transactionId, 'tx-1'); + }); + + test('confirmation can read back through a different healthy node than broadcast', async () => { + const broadcastNodes: string[] = []; + const readNodes: string[] = []; + const broadcaster = new HiveReliableBroadcaster( + network, + { + async broadcast(nodeUrl) { + broadcastNodes.push(nodeUrl); + }, + async getHeadBlock(nodeUrl) { + readNodes.push(nodeUrl ?? 'none'); + + if (readNodes.length === 1) { + throw Object.assign(new Error('HTTP 503'), { status: 503 }); + } + + return 101; + }, + async searchBlocks() { + return [row()]; + }, + }, + { + confirmationPollIntervalMs: 1, + confirmationTimeoutMs: 10, + }, + fakeClock(), + ); + + await broadcaster.broadcastSignedTransaction({ + expectedOperation: operation, + expectedSigner: 'mira-vale.dev', + transaction, + transactionId: 'tx-1', + }); + + assert.deepEqual(broadcastNodes, ['https://node-a.test']); + assert.deepEqual(readNodes, ['https://node-a.test', 'https://node-b.test']); + }); + + test('rejects a confirmed transaction id with mismatched operation content', () => { + const mismatched = buildHiveLoreCustomJsonOperation({ + action: 'canon_approval', + entityId: 'decision-2', + entityType: 'CANON_DECISION', + payload: { + contentHash: 'other', + }, + proposalId: 'proposal-1', + signer: 'mira-vale.dev', + worldId: 'world-1', + }); + + assert.throws( + () => + findAndVerifyOperation([row()], { + expectedOperation: mismatched, + expectedSigner: 'mira-vale.dev', + transactionId: 'tx-1', + }), + (error: unknown) => + error instanceof HiveBroadcastError && error.code === 'BROADCAST_REJECTED', + ); + }); + + test('matches expected operations with canonical JSON ordering', () => { + const customJson = operation.custom_json_operation; + assert.ok(customJson); + + const hivedOrderedOperation = { + custom_json_operation: { + required_auths: customJson.required_auths, + required_posting_auths: customJson.required_posting_auths, + id: customJson.id, + json: customJson.json, + }, + }; + + const confirmed = findAndVerifyOperation( + [ + { + block_num: 101, + operation: hivedOrderedOperation, + operation_id: 0, + timestamp: '2026-08-10T12:01:00.000Z', + transaction_id: 'tx-1', + }, + ], + { + expectedOperation: operation, + expectedSigner: 'mira-vale.dev', + transactionId: 'tx-1', + }, + ); + + assert.equal(confirmed?.transactionId, 'tx-1'); + }); + + test('normalizes condenser operation rows returned by transaction read-back', () => { + const customJson = operation.custom_json_operation; + assert.ok(customJson); + + const confirmed = findAndVerifyOperation( + [ + { + block: 101, + op: ['custom_json', customJson], + op_in_trx: 0, + timestamp: '2026-08-10T12:01:00.000Z', + trx_id: 'tx-1', + }, + ], + { + expectedOperation: operation, + expectedSigner: 'mira-vale.dev', + operationIndex: 0, + transactionId: 'tx-1', + }, + ); + + assert.equal(confirmed?.transactionId, 'tx-1'); + assert.equal(confirmed.operationIndex, 0); + }); + + test('ignores unrelated malformed HAF rows before normalizing candidates', () => { + const confirmed = findAndVerifyOperation( + [ + { + block_num: 101, + operation: { transfer_operation: { from: 'alice', to: 'bob' } }, + operation_id: 0, + timestamp: '2026-08-10T12:01:00.000Z', + transaction_id: 'unrelated-tx', + }, + row(), + ], + { + expectedOperation: operation, + expectedSigner: 'mira-vale.dev', + transactionId: 'tx-1', + }, + ); + + assert.equal(confirmed?.transactionId, 'tx-1'); + }); + + test('walks HAF block-search pages until the target transaction is found', async () => { + const searchedPages: number[] = []; + const broadcaster = new HiveReliableBroadcaster( + network, + { + async broadcast() {}, + async getHeadBlock() { + return 101; + }, + async searchBlocks(params) { + searchedPages.push(params.page ?? 0); + + if (params.page === 1) { + return { + operations: Array.from({ length: params.pageSize ?? 100 }, (_, index) => ({ + block_num: 101, + operation: { transfer_operation: { from: 'alice', to: 'bob' } }, + operation_id: index, + timestamp: '2026-08-10T12:01:00.000Z', + transaction_id: `unrelated-${index}`, + })), + totalPages: 2, + }; + } + + return { operations: [row()], totalPages: 2 }; + }, + }, + { + confirmationPollIntervalMs: 1, + confirmationTimeoutMs: 10, + }, + fakeClock(), + ); + + const result = await broadcaster.confirmTransaction({ + expectedOperation: operation, + expectedSigner: 'mira-vale.dev', + transactionId: 'tx-1', + }); + + assert.deepEqual(searchedPages, [1, 2]); + assert.equal(result.transactionId, 'tx-1'); + }); + + test('prefers transaction lookup over HAF block-search for confirmation', async () => { + let transactionLookups = 0; + let blockSearches = 0; + const customJson = operation.custom_json_operation; + assert.ok(customJson); + + const broadcaster = new HiveReliableBroadcaster( + network, + { + async broadcast() {}, + async getHeadBlock() { + return 101; + }, + async getTransaction() { + transactionLookups += 1; + + return [ + { + block_num: 101, + operation: { + custom_json_operation: { + required_auths: customJson.required_auths, + required_posting_auths: customJson.required_posting_auths, + id: customJson.id, + json: customJson.json, + }, + }, + operation_id: 0, + timestamp: '2026-08-10T12:01:00.000Z', + transaction_id: 'tx-1', + }, + ]; + }, + async searchBlocks() { + blockSearches += 1; + throw new Error('block-search should not be used when transaction lookup exists'); + }, + }, + { + confirmationPollIntervalMs: 1, + confirmationTimeoutMs: 10, + }, + fakeClock(), + ); + + const result = await broadcaster.confirmTransaction({ + expectedOperation: operation, + expectedSigner: 'mira-vale.dev', + transactionId: 'tx-1', + }); + + assert.equal(result.transactionId, 'tx-1'); + assert.equal(transactionLookups, 1); + assert.equal(blockSearches, 0); + }); + + test('uses block header timestamp instead of transaction expiration for lookup rows', async () => { + const customJson = operation.custom_json_operation; + assert.ok(customJson); + + const originalFetch = globalThis.fetch; + const requestedMethods: string[] = []; + + globalThis.fetch = (async (_url, init) => { + const body = JSON.parse(String(init?.body)) as { method?: string }; + requestedMethods.push(body.method ?? ''); + + if (body.method === 'condenser_api.get_transaction') { + return Response.json({ + result: { + block_num: 101, + expiration: '2026-08-10T12:05:00', + operations: [ + [ + 'custom_json', + { + id: customJson.id, + json: customJson.json, + required_auths: customJson.required_auths, + required_posting_auths: customJson.required_posting_auths, + }, + ], + ], + transaction_id: 'tx-1', + }, + }); + } + + if (body.method === 'condenser_api.get_block_header') { + return Response.json({ + result: { + timestamp: '2026-08-10T12:01:00', + }, + }); + } + + return Response.json({ error: { message: 'unexpected method' } }); + }) as typeof fetch; + + try { + const transport = new DefaultHiveBroadcastTransport(network); + const rows = await transport.getTransaction({ transactionId: 'tx-1' }); + + assert.deepEqual(requestedMethods, [ + 'condenser_api.get_transaction', + 'condenser_api.get_block_header', + ]); + assert.equal(rows?.[0]?.timestamp, '2026-08-10T12:01:00.000Z'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test('calculates capped exponential backoff with bounded jitter', () => { + assert.equal(delayForAttempt(1, DEFAULT_HIVE_RETRY_CONFIG, 0.5), 500); + assert.equal(delayForAttempt(3, DEFAULT_HIVE_RETRY_CONFIG, 0.5), 2_000); + assert.equal(delayForAttempt(6, DEFAULT_HIVE_RETRY_CONFIG, 0.5), 5_000); + assert.equal(delayForAttempt(1, DEFAULT_HIVE_RETRY_CONFIG, 0), 400); + assert.equal(delayForAttempt(1, DEFAULT_HIVE_RETRY_CONFIG, 1), 600); + }); + + test('classifies transient, permanent, and expired provider failures', () => { + assert.equal(classifyHiveBroadcastError(new Error('HTTP timeout')).failureClass, 'transient'); + assert.equal( + classifyHiveBroadcastError( + Object.assign(new Error('Hive block header lookup failed: 503 Service Unavailable'), { + status: 503, + }), + ).failureClass, + 'transient', + ); + assert.equal( + classifyHiveBroadcastError(new Error('Hive block header lookup timed out')).failureClass, + 'transient', + ); + assert.equal( + classifyHiveBroadcastError(new Error('insufficient authority')).failureClass, + 'permanent', + ); + assert.equal( + classifyHiveBroadcastError(new Error('transaction expired')).code, + 'TRANSACTION_EXPIRED', + ); + }); + + test('rotates unhealthy nodes and recovers after cooldown', () => { + let now = 0; + const pool = new HiveNodePool( + ['https://a.test', 'https://b.test'], + { + ...DEFAULT_HIVE_RETRY_CONFIG, + maxConsecutiveNodeFailures: 1, + nodeCooldownMs: 100, + }, + () => now, + ); + + assert.equal(pool.current(), 'https://a.test'); + pool.reportTransientFailure('https://a.test'); + assert.equal(pool.current(), 'https://b.test'); + now = 101; + assert.equal(pool.current(), 'https://b.test'); + pool.reportTransientFailure('https://b.test'); + assert.equal(pool.current(), 'https://a.test'); + }); + + test('validates node lists without mixing credentials or duplicates', () => { + assert.deepEqual(parseNodeList('https://a.test, https://a.test/', 'test'), ['https://a.test']); + assert.throws(() => parseNodeList('https://user:pass@a.test', 'test'), /credentials/); + assert.throws(() => parseNodeList('', 'test'), /At least one/); + }); + + test('pins mainnet to the exact Hive chain id', () => { + assert.throws( + () => + buildHiveNetworkConfig({ + customJsonId: 'hivelore', + mainnetChainId: '0'.repeat(64), + nodeEnv: 'test', + }), + /mainnet chain ID/, + ); + }); +}); diff --git a/apps/api/src/lib/hive/broadcast-reliability.ts b/apps/api/src/lib/hive/broadcast-reliability.ts new file mode 100644 index 0000000..50b7eec --- /dev/null +++ b/apps/api/src/lib/hive/broadcast-reliability.ts @@ -0,0 +1,1043 @@ +import type { ApiTransaction } from '@hiveio/wax'; + +import { hashCanonicalJson } from '../canon-voting-policy.js'; +import { verifyHiveLoreOperation } from './verification.js'; +import { normalizeHafOperation } from './projection.js'; +import { HafClient } from './haf-client.js'; +import { HiveWaxClient } from './wax-client.js'; +import { HIVE_CUSTOM_JSON_OPERATION_TYPE } from './constants.js'; +import { + DEFAULT_HIVE_RETRY_CONFIG, + type HiveNetworkConfig, + type HiveRetryConfig, + sanitizeHiveNodeUrl, +} from './network-config.js'; +import type { HafOperationRow, HiveLoreOperation, NormalizedHiveOperation } from './types.js'; + +// Server-side signed broadcasts are groundwork for future custodial/worker flows. +// The application currently uses this module primarily for confirming client-signed Hive broadcasts. + +export type BroadcastStatusCode = + | 'BROADCAST_CONFIRMED' + | 'BROADCAST_REJECTED' + | 'BROADCAST_CONFIRMATION_TIMEOUT' + | 'BROADCAST_STATUS_UNKNOWN' + | 'NETWORK_CONFIGURATION_ERROR' + | 'NETWORK_MISMATCH' + | 'TRANSACTION_EXPIRED' + | 'PERMANENT_TRANSACTION_ERROR' + | 'TRANSIENT_RPC_ERROR'; + +export type BroadcastFailureClass = 'transient' | 'permanent' | 'unknown'; + +export interface ConfirmedBroadcastResult { + network: HiveNetworkConfig['name']; + transactionId: string; + blockNumber: number; + transactionIndex?: number | undefined; + operationIndexes?: number[] | undefined; + blockchainTimestamp: string; + confirmedAt: string; + confirmationSource: 'rpc' | 'haf' | 'indexer'; + nodeUrl?: string | undefined; + attempts: number; +} + +export class HiveBroadcastError extends Error { + constructor( + public readonly code: BroadcastStatusCode, + message: string, + public readonly failureClass: BroadcastFailureClass, + public readonly diagnostics: Record = {}, + ) { + super(message); + } +} + +export interface HiveBroadcastTransport { + broadcast(nodeUrl: string, transaction: ApiTransaction, signal?: AbortSignal): Promise; + getTransaction?(params: { + transactionId: string; + nodeUrl?: string; + signal?: AbortSignal; + }): Promise; + getBlockHeader?(params: { + blockNumber: number; + nodeUrl?: string; + signal?: AbortSignal; + }): Promise<{ timestamp?: string } | null>; + searchBlocks(params: { + fromBlock?: number; + toBlock?: number; + page?: number; + pageSize?: number; + operationTypes?: number[]; + nodeUrl?: string; + signal?: AbortSignal; + }): Promise; + getHeadBlock(nodeUrl?: string, signal?: AbortSignal): Promise; +} + +export interface ConfirmOperationInput { + transactionId: string; + operationIndex?: number | undefined; + expectedSigner?: string | undefined; + expectedOperation?: HiveLoreOperation | undefined; + blockNumberHint?: number | undefined; + signal?: AbortSignal | undefined; +} + +export class DefaultHiveBroadcastTransport implements HiveBroadcastTransport { + constructor(private readonly network: HiveNetworkConfig) {} + + async broadcast(nodeUrl: string, transaction: ApiTransaction): Promise { + const client = new HiveWaxClient({ + apiEndpoint: nodeUrl, + apiTimeoutMs: DEFAULT_HIVE_RETRY_CONFIG.requestTimeoutMs, + chainId: this.network.chainId, + }); + + await client.broadcastTransaction(transaction); + } + + async searchBlocks(params: { + fromBlock?: number; + toBlock?: number; + page?: number; + pageSize?: number; + operationTypes?: number[]; + signal?: AbortSignal; + }) { + const client = new HafClient( + this.network.hafUrl + ? { + baseUrl: this.network.hafUrl, + requestTimeoutMs: DEFAULT_HIVE_RETRY_CONFIG.requestTimeoutMs, + } + : { requestTimeoutMs: DEFAULT_HIVE_RETRY_CONFIG.requestTimeoutMs }, + ); + return client.searchBlocks({ + ...(params.fromBlock === undefined ? {} : { fromBlock: params.fromBlock }), + ...(params.operationTypes === undefined ? {} : { operationTypes: params.operationTypes }), + ...(params.page === undefined ? {} : { page: params.page }), + ...(params.pageSize === undefined ? {} : { pageSize: params.pageSize }), + ...(params.toBlock === undefined ? {} : { toBlock: params.toBlock }), + ...(params.signal ? { signal: params.signal } : {}), + }); + } + + async getHeadBlock(_nodeUrl?: string, signal?: AbortSignal): Promise { + const client = new HafClient( + this.network.hafUrl + ? { + baseUrl: this.network.hafUrl, + requestTimeoutMs: DEFAULT_HIVE_RETRY_CONFIG.requestTimeoutMs, + } + : { requestTimeoutMs: DEFAULT_HIVE_RETRY_CONFIG.requestTimeoutMs }, + ); + + return client.getHeadBlock(signal); + } + + async getTransaction(params: { + transactionId: string; + nodeUrl?: string; + signal?: AbortSignal; + }): Promise { + const nodeUrl = params.nodeUrl ?? this.network.rpcNodes[0]; + + if (!nodeUrl) { + throw new HiveBroadcastError( + 'NETWORK_CONFIGURATION_ERROR', + 'No Hive RPC nodes are configured for transaction lookup.', + 'permanent', + ); + } + + const response = await fetch(nodeUrl, { + body: JSON.stringify({ + id: 1, + jsonrpc: '2.0', + method: 'condenser_api.get_transaction', + params: [params.transactionId], + }), + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + method: 'POST', + signal: createTimeoutSignal(DEFAULT_HIVE_RETRY_CONFIG.requestTimeoutMs, params.signal), + }); + + if (!response.ok) { + throw Object.assign( + new Error(`Hive transaction lookup failed: ${response.status} ${response.statusText}`), + { status: response.status }, + ); + } + + const body = (await response.json()) as { + error?: { message?: string }; + result?: unknown; + }; + + if (body.error) { + const message = body.error.message ?? 'Hive transaction lookup failed.'; + + if (message.toLowerCase().includes('unknown transaction')) { + return null; + } + + throw new Error(message); + } + + const blockchainTimestamp = await this.getBlockTimestamp({ + nodeUrl, + result: body.result, + signal: params.signal, + }); + + return transactionLookupToRows(body.result, params.transactionId, blockchainTimestamp); + } + + private async getBlockTimestamp(input: { + nodeUrl: string; + result: unknown; + signal?: AbortSignal | undefined; + }): Promise { + const blockNumber = getTransactionBlockNumber(input.result); + + const response = await fetch(input.nodeUrl, { + body: JSON.stringify({ + id: 1, + jsonrpc: '2.0', + method: 'condenser_api.get_block_header', + params: [blockNumber], + }), + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + method: 'POST', + signal: createTimeoutSignal(DEFAULT_HIVE_RETRY_CONFIG.requestTimeoutMs, input.signal), + }); + + if (!response.ok) { + throw Object.assign( + new Error(`Hive block header lookup failed: ${response.status} ${response.statusText}`), + { status: response.status }, + ); + } + + const body = (await response.json()) as { + error?: { message?: string }; + result?: { timestamp?: unknown }; + }; + + if (body.error) { + throw new Error(body.error.message ?? 'Hive block header lookup failed.'); + } + + const timestamp = body.result?.timestamp; + const normalizedTimestamp = + typeof timestamp === 'string' && !/[zZ]|[+-]\d\d:?\d\d$/.test(timestamp) + ? `${timestamp}Z` + : timestamp; + const parsedTimestamp = + typeof normalizedTimestamp === 'string' ? new Date(normalizedTimestamp) : null; + + if (!parsedTimestamp || Number.isNaN(parsedTimestamp.getTime())) { + throw new HiveBroadcastError( + 'BROADCAST_REJECTED', + 'Confirmed Hive block header did not include a valid timestamp.', + 'permanent', + { + blockNumber, + reason: 'missing_or_invalid_block_timestamp', + }, + ); + } + + return parsedTimestamp.toISOString(); + } +} + +export class HiveNodePool { + private readonly states: Array<{ + url: string; + consecutiveFailures: number; + unhealthyUntil: number; + }>; + private cursor = 0; + + constructor( + nodes: string[], + private readonly retry: HiveRetryConfig, + private readonly now: () => number = () => Date.now(), + ) { + this.states = nodes.map((url) => ({ consecutiveFailures: 0, unhealthyUntil: 0, url })); + } + + current(): string { + const now = this.now(); + + for (let offset = 0; offset < this.states.length; offset += 1) { + const index = (this.cursor + offset) % this.states.length; + const state = this.states[index]; + + if (state && state.unhealthyUntil <= now) { + this.cursor = index; + + return state.url; + } + } + + const fallback = this.states[this.cursor % this.states.length]; + + if (!fallback) { + throw new HiveBroadcastError( + 'NETWORK_CONFIGURATION_ERROR', + 'No Hive RPC nodes are configured.', + 'permanent', + ); + } + + return fallback.url; + } + + reportSuccess(nodeUrl: string) { + const state = this.find(nodeUrl); + + if (state) { + state.consecutiveFailures = 0; + state.unhealthyUntil = 0; + } + } + + reportTransientFailure(nodeUrl: string) { + const state = this.find(nodeUrl); + + if (!state) { + return; + } + + state.consecutiveFailures += 1; + + if (state.consecutiveFailures >= this.retry.maxConsecutiveNodeFailures) { + state.unhealthyUntil = this.now() + this.retry.nodeCooldownMs; + this.cursor = (this.states.indexOf(state) + 1) % this.states.length; + } + } + + private find(nodeUrl: string) { + return this.states.find((state) => state.url === nodeUrl); + } +} + +export class HiveReliableBroadcaster { + private readonly retry: HiveRetryConfig; + private readonly pool: HiveNodePool; + private readonly confirmationPool: HiveNodePool; + + constructor( + private readonly network: HiveNetworkConfig, + private readonly transport: HiveBroadcastTransport = new DefaultHiveBroadcastTransport(network), + retry: Partial = {}, + private readonly clock: { + now(): number; + sleep(ms: number, signal?: AbortSignal): Promise; + random(): number; + } = systemClock, + ) { + this.retry = { ...DEFAULT_HIVE_RETRY_CONFIG, ...retry }; + this.pool = new HiveNodePool(network.rpcNodes, this.retry, () => this.clock.now()); + this.confirmationPool = new HiveNodePool(network.rpcNodes, this.retry, () => this.clock.now()); + } + + async broadcastSignedTransaction(input: { + transaction: ApiTransaction; + transactionId: string; + expectedSigner?: string | undefined; + expectedOperation?: HiveLoreOperation | undefined; + signal?: AbortSignal | undefined; + }): Promise { + const startedAt = this.clock.now(); + let attempts = 0; + let lastError: HiveBroadcastError | null = null; + + while (attempts < this.retry.maxAttempts) { + throwIfAborted(input.signal); + + if (this.clock.now() - startedAt > this.retry.totalDeadlineMs) { + break; + } + + attempts += 1; + const nodeUrl = this.pool.current(); + const remainingDeadlineMs = this.retry.totalDeadlineMs - (this.clock.now() - startedAt); + + try { + await this.transport.broadcast(nodeUrl, input.transaction, input.signal); + this.pool.reportSuccess(nodeUrl); + + return await this.confirmAfterBroadcast({ + attempts, + expectedOperation: input.expectedOperation, + expectedSigner: input.expectedSigner, + confirmationTimeoutMs: Math.min(this.retry.confirmationTimeoutMs, remainingDeadlineMs), + nodeUrl, + signal: input.signal, + transactionId: input.transactionId, + }); + } catch (error) { + const classified = classifyHiveBroadcastError(error); + lastError = classified; + + if (classified.failureClass === 'permanent') { + throw classified; + } + + this.pool.reportTransientFailure(nodeUrl); + + const ambiguous = isAmbiguousBroadcastError(classified); + const confirmation = ambiguous + ? await this.tryConfirm({ + attempts, + confirmationTimeoutMs: Math.min( + this.retry.confirmationTimeoutMs, + Math.max(0, this.retry.totalDeadlineMs - (this.clock.now() - startedAt)), + ), + expectedOperation: input.expectedOperation, + expectedSigner: input.expectedSigner, + nodeUrl, + signal: input.signal, + transactionId: input.transactionId, + }) + : null; + + if (confirmation) { + return confirmation; + } + + if (attempts >= this.retry.maxAttempts) { + break; + } + + await this.clock.sleep( + delayForAttempt(attempts, this.retry, this.clock.random()), + input.signal, + ); + } + } + + throw new HiveBroadcastError( + lastError?.code === 'TRANSIENT_RPC_ERROR' + ? 'BROADCAST_STATUS_UNKNOWN' + : 'BROADCAST_CONFIRMATION_TIMEOUT', + 'Hive transaction broadcast status is unknown after bounded retries.', + 'unknown', + { + attempts, + network: this.network.name, + transactionId: input.transactionId, + }, + ); + } + + async confirmTransaction(input: ConfirmOperationInput): Promise { + const operation = await this.confirmTransactionOperation(input); + + return toConfirmedBroadcastResult({ + attempts: 0, + network: this.network, + operation, + }); + } + + async confirmTransactionOperation( + input: ConfirmOperationInput, + ): Promise { + const operation = await pollForConfirmedOperation({ + confirmationTimeoutMs: this.retry.confirmationTimeoutMs, + clock: this.clock, + nodePool: this.confirmationPool, + input, + network: this.network, + pollIntervalMs: this.retry.confirmationPollIntervalMs, + transport: this.transport, + }); + + if (!operation) { + throw new HiveBroadcastError( + 'BROADCAST_CONFIRMATION_TIMEOUT', + 'Hive transaction was not found before the confirmation timeout.', + 'unknown', + { + network: this.network.name, + transactionId: input.transactionId, + }, + ); + } + + return operation; + } + + private async confirmAfterBroadcast(input: { + attempts: number; + transactionId: string; + expectedSigner?: string | undefined; + expectedOperation?: HiveLoreOperation | undefined; + confirmationTimeoutMs?: number | undefined; + nodeUrl: string; + signal?: AbortSignal | undefined; + }): Promise { + const confirmed = await this.tryConfirm(input); + + if (confirmed) { + return confirmed; + } + + throw new HiveBroadcastError( + 'BROADCAST_STATUS_UNKNOWN', + 'Hive transaction broadcast was accepted but confirmation is still pending.', + 'unknown', + { + attempts: input.attempts, + network: this.network.name, + transactionId: input.transactionId, + }, + ); + } + + private async tryConfirm(input: { + attempts: number; + transactionId: string; + expectedSigner?: string | undefined; + expectedOperation?: HiveLoreOperation | undefined; + confirmationTimeoutMs?: number | undefined; + nodeUrl: string; + signal?: AbortSignal | undefined; + }): Promise { + let operation: NormalizedHiveOperation | null; + + try { + operation = await pollForConfirmedOperation({ + confirmationTimeoutMs: input.confirmationTimeoutMs ?? this.retry.confirmationTimeoutMs, + clock: this.clock, + nodePool: this.confirmationPool, + input, + network: this.network, + pollIntervalMs: this.retry.confirmationPollIntervalMs, + transport: this.transport, + }); + } catch (error) { + if (error instanceof HiveBroadcastError && error.code === 'BROADCAST_CONFIRMATION_TIMEOUT') { + return null; + } + + throw error; + } + + return operation + ? toConfirmedBroadcastResult({ + attempts: input.attempts, + network: this.network, + nodeUrl: sanitizeHiveNodeUrl(input.nodeUrl), + operation, + }) + : null; + } +} + +export function classifyHiveBroadcastError(error: unknown): HiveBroadcastError { + if (error instanceof HiveBroadcastError) { + return error; + } + + const detail = error instanceof Error ? error.message : String(error); + const lower = detail.toLowerCase(); + const status = + typeof error === 'object' && error ? Number((error as { status?: unknown }).status) : 0; + + if (lower.includes('expired')) { + return new HiveBroadcastError('TRANSACTION_EXPIRED', 'Hive transaction expired.', 'permanent'); + } + + if ( + lower.includes('missing required signature') || + lower.includes('invalid signature') || + lower.includes('tx_missing_posting_auth') || + lower.includes('insufficient authority') || + lower.includes('malformed') || + lower.includes('parse') || + lower.includes('insufficient rc') || + lower.includes('resource credits') + ) { + return new HiveBroadcastError( + 'PERMANENT_TRANSACTION_ERROR', + 'Hive transaction was permanently rejected.', + 'permanent', + ); + } + + if (lower.includes('block metadata') || lower.includes('transaction id mismatch')) { + return new HiveBroadcastError( + 'BROADCAST_REJECTED', + 'Hive confirmation failed closed on invalid read-back metadata.', + 'permanent', + { reason: lower.includes('transaction id mismatch') ? 'transaction_id_mismatch' : undefined }, + ); + } + + if ( + status === 408 || + status === 429 || + status >= 500 || + lower.includes('timeout') || + lower.includes('timed out') || + lower.includes('econnreset') || + lower.includes('enotfound') || + lower.includes('network') || + lower.includes('temporarily unavailable') || + lower.includes('bad gateway') || + lower.includes('already known') || + lower.includes('duplicate transaction') + ) { + return new HiveBroadcastError( + 'TRANSIENT_RPC_ERROR', + 'Hive RPC request failed transiently.', + 'transient', + { status: status || undefined }, + ); + } + + return new HiveBroadcastError( + 'BROADCAST_STATUS_UNKNOWN', + 'Hive broadcast failed with an unclassified error.', + 'unknown', + ); +} + +export function delayForAttempt(attempt: number, retry: HiveRetryConfig, random: number): number { + const base = Math.min( + retry.maxDelayMs, + retry.initialDelayMs * retry.backoffMultiplier ** Math.max(0, attempt - 1), + ); + const jitter = base * retry.jitterRatio * (random * 2 - 1); + + return Math.max(1, Math.round(base + jitter)); +} + +export async function pollForConfirmedOperation(input: { + input: ConfirmOperationInput; + transport: HiveBroadcastTransport; + network: HiveNetworkConfig; + nodePool?: HiveNodePool | undefined; + confirmationTimeoutMs: number; + pollIntervalMs: number; + clock: { + now(): number; + sleep(ms: number, signal?: AbortSignal): Promise; + }; +}): Promise { + const startedAt = input.clock.now(); + let fromBlock = input.input.blockNumberHint; + const pageSize = 100; + let lastFailure: HiveBroadcastError | null = null; + let sawTransaction = false; + + while (input.clock.now() - startedAt <= input.confirmationTimeoutMs) { + throwIfAborted(input.input.signal); + + const nodeUrl = input.nodePool?.current(); + + try { + if (input.transport.getTransaction) { + const transactionRows = await input.transport.getTransaction({ + ...(nodeUrl ? { nodeUrl } : {}), + ...(input.input.signal ? { signal: input.input.signal } : {}), + transactionId: input.input.transactionId, + }); + + if (transactionRows) { + sawTransaction = true; + const confirmed = findAndVerifyOperation(transactionRows, input.input); + + if (confirmed) { + if (nodeUrl) { + input.nodePool?.reportSuccess(nodeUrl); + } + + return confirmed; + } + } + + if (nodeUrl) { + input.nodePool?.reportSuccess(nodeUrl); + } + + await input.clock.sleep(input.pollIntervalMs, input.input.signal); + continue; + } + + const head = fromBlock + ? fromBlock + : await input.transport.getHeadBlock(nodeUrl, input.input.signal); + const searchFromBlock = fromBlock ?? Math.max(1, head - 1_200); + const searchToBlock = fromBlock ?? head; + let page = 1; + + while (input.clock.now() - startedAt <= input.confirmationTimeoutMs) { + throwIfAborted(input.input.signal); + + const searchPage = normalizeSearchPage( + await input.transport.searchBlocks({ + fromBlock: searchFromBlock, + ...(nodeUrl ? { nodeUrl } : {}), + operationTypes: [HIVE_CUSTOM_JSON_OPERATION_TYPE], + page, + pageSize, + toBlock: searchToBlock, + ...(input.input.signal ? { signal: input.input.signal } : {}), + }), + ); + const confirmed = findAndVerifyOperation(searchPage.operations, input.input); + + if (confirmed) { + if (nodeUrl) { + input.nodePool?.reportSuccess(nodeUrl); + } + + return confirmed; + } + + if ( + searchPage.totalPages !== undefined + ? page >= searchPage.totalPages + : searchPage.operations.length < pageSize + ) { + break; + } + + page += 1; + } + + if (nodeUrl) { + input.nodePool?.reportSuccess(nodeUrl); + } + } catch (error) { + const classified = classifyHiveBroadcastError(error); + lastFailure = classified; + + if (classified.failureClass === 'permanent') { + throw classified; + } + + if (nodeUrl) { + input.nodePool?.reportTransientFailure(nodeUrl); + } + } + + fromBlock = undefined; + await input.clock.sleep(input.pollIntervalMs, input.input.signal); + } + + if (lastFailure) { + throw new HiveBroadcastError( + 'BROADCAST_CONFIRMATION_TIMEOUT', + 'Hive transaction was not confirmed before the timeout.', + 'unknown', + { + lastFailure: { + code: lastFailure.code, + diagnostics: lastFailure.diagnostics, + failureClass: lastFailure.failureClass, + message: lastFailure.message, + }, + reason: 'last_rpc_or_response_failure', + transactionId: input.input.transactionId, + }, + ); + } + + if (!sawTransaction) { + throw new HiveBroadcastError( + 'BROADCAST_CONFIRMATION_TIMEOUT', + 'Hive transaction was not found before the confirmation timeout.', + 'unknown', + { + reason: 'transaction_not_found_yet', + transactionId: input.input.transactionId, + }, + ); + } + + return null; +} + +export function findAndVerifyOperation( + rows: HafOperationRow[], + input: ConfirmOperationInput, +): NormalizedHiveOperation | null { + for (const row of rows) { + if (!rowMatchesTransaction(row, input.transactionId)) { + continue; + } + + if (!rowMatchesOperationIndex(row, input.operationIndex)) { + continue; + } + + let operation: NormalizedHiveOperation; + + try { + operation = normalizeHafOperation(row); + } catch (error) { + if (input.operationIndex === undefined) { + continue; + } + + throw new HiveBroadcastError( + 'BROADCAST_REJECTED', + 'Confirmed Hive row for the transaction could not be normalized.', + 'permanent', + { + cause: error instanceof Error ? error.message : String(error), + reason: 'operation_normalization_failed', + }, + ); + } + + const verification = verifyHiveLoreOperation({ + ...(input.expectedSigner ? { expectedSigner: input.expectedSigner } : {}), + operation: operation.operation, + }); + + if (!verification.ok) { + throw new HiveBroadcastError( + 'BROADCAST_REJECTED', + verification.reason ?? 'Confirmed Hive operation did not pass verification.', + 'permanent', + { reason: verification.reason ?? 'operation_verification_failed' }, + ); + } + + if (input.expectedOperation && !operationsEqual(operation.operation, input.expectedOperation)) { + throw new HiveBroadcastError( + 'BROADCAST_REJECTED', + 'Confirmed Hive operation does not match the expected operation.', + 'permanent', + { reason: 'payload_mismatch' }, + ); + } + + return operation; + } + + return null; +} + +function toConfirmedBroadcastResult(input: { + network: HiveNetworkConfig; + operation: NormalizedHiveOperation; + attempts: number; + nodeUrl?: string | undefined; +}): ConfirmedBroadcastResult { + return { + attempts: input.attempts, + blockchainTimestamp: input.operation.blockchainTimestamp.toISOString(), + blockNumber: Number(input.operation.blockNumber), + confirmationSource: 'haf', + confirmedAt: new Date().toISOString(), + network: input.network.name, + nodeUrl: input.nodeUrl, + operationIndexes: [input.operation.operationIndex], + transactionId: input.operation.transactionId, + }; +} + +function isAmbiguousBroadcastError(error: HiveBroadcastError) { + return error.failureClass !== 'permanent'; +} + +function operationsEqual(left: HiveLoreOperation, right: HiveLoreOperation) { + return hashCanonicalJson(left) === hashCanonicalJson(right); +} + +function normalizeSearchPage( + page: HafOperationRow[] | { operations: HafOperationRow[]; totalPages?: number }, +) { + return Array.isArray(page) ? { operations: page } : page; +} + +function transactionLookupToRows( + result: unknown, + transactionId: string, + blockchainTimestamp: string, +): HafOperationRow[] | null { + if (!result || typeof result !== 'object') { + return null; + } + + const transaction = result as { + block_num?: number | string; + blockNumber?: number | string; + block?: number | string; + operations?: unknown; + transaction_id?: string; + transactionId?: string; + trx_id?: string; + }; + + if (!Array.isArray(transaction.operations)) { + return []; + } + + const resolvedTransactionId = + transaction.transaction_id ?? transaction.transactionId ?? transaction.trx_id ?? transactionId; + + if (resolvedTransactionId !== transactionId) { + throw new HiveBroadcastError( + 'BROADCAST_REJECTED', + 'Hive transaction lookup returned a transaction ID mismatch.', + 'permanent', + { + reason: 'transaction_id_mismatch', + requestedTransactionId: transactionId, + returnedTransactionId: resolvedTransactionId, + }, + ); + } + + return transaction.operations.map((operationValue, index) => { + const blockNumber = transaction.block_num ?? transaction.blockNumber ?? transaction.block; + + return { + ...(blockNumber === undefined ? {} : { block_num: blockNumber }), + operation: normalizeCondenserOperation(operationValue), + operation_id: index, + timestamp: blockchainTimestamp, + transaction_id: resolvedTransactionId, + }; + }); +} + +function getTransactionBlockNumber(result: unknown): number { + if (!result || typeof result !== 'object') { + throw new HiveBroadcastError( + 'BROADCAST_REJECTED', + 'Confirmed Hive transaction lookup did not include usable block metadata.', + 'permanent', + { reason: 'missing_transaction_result' }, + ); + } + + const transaction = result as { + block_num?: number | string; + blockNumber?: number | string; + block?: number | string; + }; + const rawBlockNumber = transaction.block_num ?? transaction.blockNumber ?? transaction.block; + const blockNumber = typeof rawBlockNumber === 'string' ? Number(rawBlockNumber) : rawBlockNumber; + + if (typeof blockNumber !== 'number' || !Number.isInteger(blockNumber) || blockNumber <= 0) { + throw new HiveBroadcastError( + 'BROADCAST_REJECTED', + 'Confirmed Hive transaction lookup did not include usable block metadata.', + 'permanent', + { reason: 'missing_or_invalid_block_number' }, + ); + } + + return blockNumber; +} + +function normalizeCondenserOperation(operationValue: unknown): unknown { + if (Array.isArray(operationValue) && operationValue.length >= 2) { + const [type, value] = operationValue; + + if (type === 'comment') { + return { comment_operation: value }; + } + + if (type === 'custom_json') { + return { custom_json_operation: value }; + } + } + + return operationValue; +} + +function rowMatchesTransaction(row: HafOperationRow, transactionId: string) { + return (row.transaction_id ?? row.transactionId ?? row.trx_id) === transactionId; +} + +function rowMatchesOperationIndex(row: HafOperationRow, operationIndex: number | undefined) { + if (operationIndex === undefined) { + return true; + } + + const rawIndex = row.operation_id ?? row.operationIndex ?? row.op_in_trx ?? row.op_pos; + const numericIndex = typeof rawIndex === 'string' ? Number(rawIndex) : rawIndex; + + return numericIndex === operationIndex; +} + +function throwIfAborted(signal: AbortSignal | undefined) { + if (signal?.aborted) { + throw new HiveBroadcastError( + 'BROADCAST_STATUS_UNKNOWN', + 'Hive broadcast was cancelled.', + 'unknown', + ); + } +} + +function createTimeoutSignal(timeoutMs: number, parentSignal?: AbortSignal): AbortSignal { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + if (parentSignal?.aborted) { + clearTimeout(timeout); + controller.abort(); + return controller.signal; + } + + parentSignal?.addEventListener( + 'abort', + () => { + clearTimeout(timeout); + controller.abort(); + }, + { once: true }, + ); + + controller.signal.addEventListener('abort', () => clearTimeout(timeout), { once: true }); + + return controller.signal; +} + +const systemClock = { + now: () => Date.now(), + random: () => Math.random(), + sleep(ms: number, signal?: AbortSignal) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(resolve, ms); + + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timeout); + reject( + new HiveBroadcastError( + 'BROADCAST_STATUS_UNKNOWN', + 'Hive broadcast was cancelled.', + 'unknown', + ), + ); + }, + { once: true }, + ); + }); + }, +}; diff --git a/apps/api/src/lib/hive/client.ts b/apps/api/src/lib/hive/client.ts index f9b77ea..52b0ff6 100644 --- a/apps/api/src/lib/hive/client.ts +++ b/apps/api/src/lib/hive/client.ts @@ -1,16 +1,64 @@ import { env } from '../../config/env.js'; +import { HiveReliableBroadcaster } from './broadcast-reliability.js'; import { HafClient } from './haf-client.js'; +import { HIVELORE_CUSTOM_JSON_ID } from './constants.js'; +import { + buildHiveNetworkConfig, + validateHiveRetryConfig, + type HiveRetryConfig, +} from './network-config.js'; import { HiveWaxClient } from './wax-client.js'; +export function createHiveNetworkConfig() { + return buildHiveNetworkConfig({ + customJsonId: HIVELORE_CUSTOM_JSON_ID, + mainnetChainId: env.HIVE_MAINNET_CHAIN_ID, + mainnetHafUrl: env.HIVE_MAINNET_HAF_URL, + mainnetRpcNodes: env.HIVE_MAINNET_RPC_NODES || env.HIVE_RPC_URL, + nodeEnv: env.NODE_ENV, + }); +} + +export function createHiveRetryConfig(): HiveRetryConfig { + return validateHiveRetryConfig({ + backoffMultiplier: env.HIVE_BROADCAST_BACKOFF_MULTIPLIER, + confirmationPollIntervalMs: env.HIVE_CONFIRMATION_POLL_INTERVAL_MS, + confirmationTimeoutMs: env.HIVE_CONFIRMATION_TIMEOUT_MS, + initialDelayMs: env.HIVE_BROADCAST_INITIAL_DELAY_MS, + jitterRatio: env.HIVE_BROADCAST_JITTER_RATIO, + maxAttempts: env.HIVE_BROADCAST_MAX_ATTEMPTS, + maxConsecutiveNodeFailures: env.HIVE_NODE_MAX_CONSECUTIVE_FAILURES, + maxDelayMs: env.HIVE_BROADCAST_MAX_DELAY_MS, + nodeCooldownMs: env.HIVE_NODE_COOLDOWN_MS, + requestTimeoutMs: env.HIVE_BROADCAST_TIMEOUT_MS, + totalDeadlineMs: env.HIVE_BROADCAST_TOTAL_DEADLINE_MS, + }); +} + export function createHiveWaxClient() { + const network = createHiveNetworkConfig(); + const [apiEndpoint] = network.rpcNodes; + + if (!apiEndpoint) { + throw new Error('At least one Hive RPC node must be configured.'); + } + return new HiveWaxClient({ - apiEndpoint: env.HIVE_RPC_URL, + apiEndpoint, appName: env.HIVELORE_APP_ID, + apiTimeoutMs: env.HIVE_BROADCAST_TIMEOUT_MS, + chainId: network.chainId, }); } export function createHafClient() { + const network = createHiveNetworkConfig(); + return new HafClient({ - baseUrl: env.HAF_API_URL, + baseUrl: network.hafUrl ?? env.HAF_API_URL, }); } + +export function createHiveReliableBroadcaster() { + return new HiveReliableBroadcaster(createHiveNetworkConfig(), undefined, createHiveRetryConfig()); +} diff --git a/apps/api/src/lib/hive/constants.ts b/apps/api/src/lib/hive/constants.ts index 3ba64c9..5995fc2 100644 --- a/apps/api/src/lib/hive/constants.ts +++ b/apps/api/src/lib/hive/constants.ts @@ -5,5 +5,7 @@ export const DEFAULT_HIVE_RPC_URL = 'https://api.hive.blog'; export const DEFAULT_HAF_API_URL = 'https://api.hive.blog/hafbe-api'; export const DEFAULT_HIVELORE_APP_ID = 'hivelore/0.1.0'; export const HIVELORE_CUSTOM_JSON_ID = 'hivelore'; +export const HIVELORE_SMOKE_CUSTOM_JSON_ID = 'hivelore_smoke'; +export const HIVE_CUSTOM_JSON_OPERATION_TYPE = 18; export const HIVELORE_SCHEMA_VERSION = 1; diff --git a/apps/api/src/lib/hive/haf-client.ts b/apps/api/src/lib/hive/haf-client.ts index 82d6aa1..619394f 100644 --- a/apps/api/src/lib/hive/haf-client.ts +++ b/apps/api/src/lib/hive/haf-client.ts @@ -4,23 +4,26 @@ import type { HafBlockSearchPage, HafOperationRow } from './types.js'; export interface HafClientOptions { baseUrl?: string; fetchImpl?: typeof fetch; + requestTimeoutMs?: number; } export class HafClient { private readonly baseUrl: string; private readonly fetchImpl: typeof fetch; + private readonly requestTimeoutMs: number; constructor(options: HafClientOptions = {}) { this.baseUrl = (options.baseUrl ?? DEFAULT_HAF_API_URL).replace(/\/$/, ''); this.fetchImpl = options.fetchImpl ?? fetch; + this.requestTimeoutMs = options.requestTimeoutMs ?? 10_000; } - async getHeadBlock(): Promise { - return this.getLatestSyncedBlock(); + async getHeadBlock(signal?: AbortSignal): Promise { + return this.getLatestSyncedBlock(signal); } - async getLatestSyncedBlock(): Promise { - const value = await this.getJson('/last-synced-block'); + async getLatestSyncedBlock(signal?: AbortSignal): Promise { + const value = await this.getJson('/last-synced-block', undefined, signal); if (typeof value !== 'number' || !Number.isInteger(value)) { throw new Error('HAF last-synced-block response did not include a block number.'); @@ -47,14 +50,19 @@ export class HafClient { toBlock?: number; page?: number; pageSize?: number; + signal?: AbortSignal; }): Promise { - const response = await this.getJson('/block-search', { - 'operation-types': params.operationTypes?.join(','), - 'from-block': params.fromBlock, - 'to-block': params.toBlock, - page: params.page, - 'page-size': params.pageSize, - }); + const response = await this.getJson( + '/block-search', + { + 'operation-types': params.operationTypes?.join(','), + 'from-block': params.fromBlock, + 'to-block': params.toBlock, + page: params.page, + 'page-size': params.pageSize, + }, + params.signal, + ); return parseBlockSearchPage(response); } @@ -62,6 +70,7 @@ export class HafClient { private async getJson( path: string, params?: Record, + signal?: AbortSignal, ): Promise { const url = new URL(`${this.baseUrl}${path}`); @@ -71,7 +80,9 @@ export class HafClient { } } - const response = await this.fetchImpl(url); + const response = await this.fetchImpl(url, { + signal: createTimeoutSignal(this.requestTimeoutMs, signal), + }); if (!response.ok) { throw new Error(`HAF request failed: ${response.status} ${response.statusText}`); @@ -81,6 +92,30 @@ export class HafClient { } } +function createTimeoutSignal(timeoutMs: number, parentSignal?: AbortSignal): AbortSignal { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + if (parentSignal?.aborted) { + clearTimeout(timeout); + controller.abort(); + return controller.signal; + } + + parentSignal?.addEventListener( + 'abort', + () => { + clearTimeout(timeout); + controller.abort(); + }, + { once: true }, + ); + + controller.signal.addEventListener('abort', () => clearTimeout(timeout), { once: true }); + + return controller.signal; +} + export function parseBlockSearchPage(response: unknown): HafBlockSearchPage { if (Array.isArray(response)) { return { operations: response as HafOperationRow[] }; diff --git a/apps/api/src/lib/hive/index.ts b/apps/api/src/lib/hive/index.ts index b8624a4..eda02fe 100644 --- a/apps/api/src/lib/hive/index.ts +++ b/apps/api/src/lib/hive/index.ts @@ -1,10 +1,14 @@ +export * from './broadcast-reliability.js'; export * from './client.js'; export * from './constants.js'; export * from './haf-client.js'; +export * from './network-config.js'; +export * from './mainnet-smoke.js'; export * from './operations.js'; export * from './publishing.js'; export * from './projection.js'; export * from './signers.js'; +export * from './smoke-operation.js'; export * from './types.js'; export * from './verification.js'; export * from './wax-client.js'; diff --git a/apps/api/src/lib/hive/mainnet-smoke.test.ts b/apps/api/src/lib/hive/mainnet-smoke.test.ts new file mode 100644 index 0000000..fbcb6ce --- /dev/null +++ b/apps/api/src/lib/hive/mainnet-smoke.test.ts @@ -0,0 +1,178 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import type { ApiOperation, ApiTransaction } from '@hiveio/wax'; + +import { formatHiveMainnetSmokeResult, runHiveMainnetSmoke } from './mainnet-smoke.js'; +import { + HIVELORE_MAINNET_SMOKE_PAYLOAD, + assertPostingAuthorityOnly, + buildHiveLoreSmokeCustomJsonOperation, + buildMainnetSmokeNetworkFromEnv, + verifyHiveLoreSmokeOperation, +} from './smoke-operation.js'; +import type { BuiltHiveTransaction, SignedHiveTransaction } from './types.js'; +import type { HiveWaxClient } from './wax-client.js'; + +const network = buildMainnetSmokeNetworkFromEnv({ + HIVE_MAINNET_RPC_NODES: 'https://node-a.test,https://node-b.test', + NODE_ENV: 'test', +}); + +const unsignedTransaction: ApiTransaction = { + expiration: '2026-08-15T12:10:00', + extensions: [], + operations: [], + ref_block_num: 1, + ref_block_prefix: 2, + signatures: [], +}; + +describe('Hive mainnet smoke test', () => { + test('builds the hivelore_smoke operation with posting authority only', () => { + const operation = buildHiveLoreSmokeCustomJsonOperation('Mira-Vale.Dev'); + const customJson = operation.custom_json_operation; + + assert.ok(customJson); + assert.equal(customJson.id, 'hivelore_smoke'); + assert.deepEqual(customJson.required_auths, []); + assert.deepEqual(customJson.required_posting_auths, ['mira-vale.dev']); + assert.deepEqual(JSON.parse(customJson.json), HIVELORE_MAINNET_SMOKE_PAYLOAD); + assert.deepEqual(verifyHiveLoreSmokeOperation({ expectedSigner: 'mira-vale.dev', operation }), { + ok: true, + payload: HIVELORE_MAINNET_SMOKE_PAYLOAD, + signer: 'mira-vale.dev', + }); + }); + + test('fails closed if a smoke operation requests active authority', () => { + const operation = buildHiveLoreSmokeCustomJsonOperation('mira-vale.dev'); + + assert.ok(operation.custom_json_operation); + operation.custom_json_operation.required_auths = ['mira-vale.dev']; + operation.custom_json_operation.required_posting_auths = []; + + assert.throws(() => assertPostingAuthorityOnly(operation), /BUG/); + assert.deepEqual(verifyHiveLoreSmokeOperation({ expectedSigner: 'mira-vale.dev', operation }), { + ok: false, + reason: 'Smoke custom_json must not require active authority.', + }); + }); + + test('rejects non-mainnet smoke configuration before broadcasting', () => { + assert.throws( + () => + buildMainnetSmokeNetworkFromEnv({ + HIVE_MAINNET_CHAIN_ID: '0'.repeat(64), + HIVE_MAINNET_RPC_NODES: 'https://node-a.test', + NODE_ENV: 'test', + }), + /mainnet chain ID/, + ); + }); + + test('broadcasts through the reliability path and confirms by transaction read-back', async () => { + let builtOperation = buildHiveLoreSmokeCustomJsonOperation('mira-vale.dev'); + const signedTransaction: ApiTransaction = { + ...unsignedTransaction, + signatures: ['stub-signature'], + }; + const transactionLookups: string[] = []; + const blockSearches: string[] = []; + const broadcasts: string[] = []; + const customJsonOperation = builtOperation.custom_json_operation; + + assert.ok(customJsonOperation); + + const transactionOperations: ApiOperation[] = [ + { + type: 'custom_json_operation', + value: customJsonOperation as unknown as Record, + }, + ]; + const waxClient = { + async buildTransaction(operations: Parameters[0]) { + builtOperation = operations[0] ?? builtOperation; + + return { + binaryHex: 'binary', + requiredAuthorities: { posting: ['mira-vale.dev'] }, + transaction: { + ...unsignedTransaction, + operations: transactionOperations, + }, + unsignedBinaryHex: 'unsigned-binary', + } satisfies BuiltHiveTransaction; + }, + async signTransaction(): Promise { + return { + binaryHex: 'signed-binary', + transaction: signedTransaction, + transactionId: 'tx-smoke-1', + } satisfies SignedHiveTransaction; + }, + } as unknown as HiveWaxClient; + + const result = await runHiveMainnetSmoke({ + account: 'mira-vale.dev', + network, + postingKey: 'stub-not-used-by-fake-client', + transport: { + async broadcast(nodeUrl) { + broadcasts.push(nodeUrl); + }, + async getHeadBlock() { + return 101; + }, + async getTransaction(params) { + transactionLookups.push(params.nodeUrl ?? 'none'); + + return [ + { + block_num: 101, + operation: builtOperation, + operation_id: 0, + timestamp: '2026-08-15T12:00:00.000Z', + transaction_id: params.transactionId, + }, + ]; + }, + async searchBlocks(params) { + blockSearches.push(params.nodeUrl ?? 'none'); + + return []; + }, + }, + waxClient, + }); + + assert.deepEqual(broadcasts, ['https://node-a.test']); + assert.deepEqual(transactionLookups, ['https://node-a.test']); + assert.deepEqual(blockSearches, []); + assert.deepEqual(result, { + blockNumber: 101, + blockTimestamp: '2026-08-15T12:00:00.000Z', + rpcNodeUsed: 'https://node-a.test', + transactionId: 'tx-smoke-1', + verificationSummary: 'PASS', + }); + }); + + test('formats only the approved confirmation fields', () => { + assert.equal( + formatHiveMainnetSmokeResult({ + blockNumber: 101, + blockTimestamp: '2026-08-15T12:00:00.000Z', + rpcNodeUsed: 'https://node-a.test', + transactionId: 'tx-smoke-1', + verificationSummary: 'PASS', + }), + [ + 'transaction ID: tx-smoke-1', + 'block number: 101', + 'block timestamp: 2026-08-15T12:00:00.000Z', + 'RPC node used: https://node-a.test', + 'verification summary: PASS', + ].join('\n'), + ); + }); +}); diff --git a/apps/api/src/lib/hive/mainnet-smoke.ts b/apps/api/src/lib/hive/mainnet-smoke.ts new file mode 100644 index 0000000..48dec13 --- /dev/null +++ b/apps/api/src/lib/hive/mainnet-smoke.ts @@ -0,0 +1,129 @@ +import type { ApiTransaction } from '@hiveio/wax'; +import { PrivateKey, cryptoUtils } from '@hiveio/dhive'; + +import type { HiveNetworkConfig } from './network-config.js'; +import { hiveAccountNameSchema } from './operations.js'; +import { HiveReliableBroadcaster, type HiveBroadcastTransport } from './broadcast-reliability.js'; +import { HiveWaxClient } from './wax-client.js'; +import { + assertPostingAuthorityOnly, + buildHiveLoreSmokeCustomJsonOperation, + verifyHiveLoreSmokeOperation, +} from './smoke-operation.js'; + +export interface HiveMainnetSmokeResult { + transactionId: string; + blockNumber: number; + blockTimestamp: string; + rpcNodeUsed: string; + verificationSummary: 'PASS' | 'FAIL'; +} + +export interface RunHiveMainnetSmokeInput { + account: string; + postingKey: string; + network: HiveNetworkConfig; + waxClient?: HiveWaxClient | undefined; + transport?: HiveBroadcastTransport | undefined; +} + +export async function runHiveMainnetSmoke( + input: RunHiveMainnetSmokeInput, +): Promise { + const signer = hiveAccountNameSchema.parse(input.account); + const operation = buildHiveLoreSmokeCustomJsonOperation(signer); + const smokeVerification = verifyHiveLoreSmokeOperation({ expectedSigner: signer, operation }); + + if (!smokeVerification.ok) { + throw new Error(smokeVerification.reason); + } + + assertPostingAuthorityOnly(operation); + + const primaryRpcNode = input.network.rpcNodes[0]; + + if (!primaryRpcNode) { + throw new Error('Hive mainnet smoke test requires at least one RPC node.'); + } + + const waxClient = + input.waxClient ?? + new HiveWaxClient({ + apiEndpoint: primaryRpcNode, + chainId: input.network.chainId, + }); + const built = await waxClient.buildTransaction([operation]); + const signed = await waxClient.signTransaction(built, { + provider: 'wax', + async signTransaction(transaction) { + return signApiTransactionWithPostingKey({ + chainId: input.network.chainId, + postingKey: input.postingKey, + transaction, + }); + }, + }); + const broadcaster = new HiveReliableBroadcaster(input.network, input.transport); + const confirmed = await broadcaster.broadcastSignedTransaction({ + expectedOperation: operation, + expectedSigner: signer, + transaction: signed.transaction, + transactionId: signed.transactionId, + }); + + return { + blockNumber: confirmed.blockNumber, + blockTimestamp: confirmed.blockchainTimestamp, + rpcNodeUsed: confirmed.nodeUrl ?? primaryRpcNode, + transactionId: confirmed.transactionId, + verificationSummary: 'PASS', + }; +} + +export function formatHiveMainnetSmokeResult(result: HiveMainnetSmokeResult): string { + return [ + `transaction ID: ${result.transactionId}`, + `block number: ${result.blockNumber}`, + `block timestamp: ${result.blockTimestamp}`, + `RPC node used: ${result.rpcNodeUsed}`, + `verification summary: ${result.verificationSummary}`, + ].join('\n'); +} + +export function signApiTransactionWithPostingKey(input: { + transaction: ApiTransaction; + postingKey: string; + chainId: string; +}): ApiTransaction { + const key = PrivateKey.fromString(input.postingKey); + const signed = cryptoUtils.signTransaction( + apiTransactionToLegacyCustomJsonTransaction(input.transaction), + key, + Buffer.from(input.chainId, 'hex'), + ); + + return { + ...input.transaction, + signatures: signed.signatures, + }; +} + +function apiTransactionToLegacyCustomJsonTransaction(transaction: ApiTransaction) { + return { + ...transaction, + operations: transaction.operations.map((operation) => { + const customJson = + 'custom_json_operation' in operation + ? operation.custom_json_operation + : operation.type === 'custom_json_operation' + ? operation.value + : undefined; + + if (!customJson) { + throw new Error('Smoke transaction may only contain custom_json operations.'); + } + + return ['custom_json', customJson] as const; + }), + }; +} diff --git a/apps/api/src/lib/hive/network-config.ts b/apps/api/src/lib/hive/network-config.ts new file mode 100644 index 0000000..8777d10 --- /dev/null +++ b/apps/api/src/lib/hive/network-config.ts @@ -0,0 +1,167 @@ +import { DEFAULT_HAF_API_URL, DEFAULT_HIVE_RPC_URL, HIVE_MAINNET_CHAIN_ID } from './constants.js'; + +export type HiveNetworkName = 'mainnet'; + +export interface HiveNetworkConfig { + name: HiveNetworkName; + chainId: string; + rpcNodes: string[]; + hafUrl?: string | undefined; + addressPrefix: 'STM'; + customJsonId: string; + transactionExpirationMinutes: number; + explorerTransactionUrl?: string | undefined; +} + +export interface HiveRetryConfig { + maxAttempts: number; + initialDelayMs: number; + maxDelayMs: number; + backoffMultiplier: number; + jitterRatio: number; + requestTimeoutMs: number; + totalDeadlineMs: number; + confirmationPollIntervalMs: number; + confirmationTimeoutMs: number; + maxConsecutiveNodeFailures: number; + nodeCooldownMs: number; +} + +export const DEFAULT_HIVE_RETRY_CONFIG: HiveRetryConfig = { + backoffMultiplier: 2, + confirmationPollIntervalMs: 3_000, + confirmationTimeoutMs: 60_000, + initialDelayMs: 500, + jitterRatio: 0.2, + maxAttempts: 4, + maxConsecutiveNodeFailures: 1, + maxDelayMs: 5_000, + nodeCooldownMs: 30_000, + requestTimeoutMs: 10_000, + totalDeadlineMs: 90_000, +}; + +export function buildHiveNetworkConfig(input: { + mainnetChainId?: string | undefined; + mainnetRpcNodes?: string | undefined; + mainnetHafUrl?: string | undefined; + customJsonId: string; + nodeEnv?: string | undefined; +}): HiveNetworkConfig { + const chainId = input.mainnetChainId || HIVE_MAINNET_CHAIN_ID; + const rpcNodes = parseNodeList(input.mainnetRpcNodes || DEFAULT_HIVE_RPC_URL, input.nodeEnv); + const hafUrl = input.mainnetHafUrl || DEFAULT_HAF_API_URL; + + validateChainId(chainId); + + if (chainId !== HIVE_MAINNET_CHAIN_ID) { + throw new Error('HIVE_MAINNET_CHAIN_ID must match the Hive mainnet chain ID.'); + } + + return { + addressPrefix: 'STM', + chainId, + customJsonId: input.customJsonId, + explorerTransactionUrl: 'https://hiveblocks.com/tx/{transactionId}', + hafUrl: hafUrl || undefined, + name: 'mainnet', + rpcNodes, + transactionExpirationMinutes: 10, + }; +} + +export function parseNodeList(value: string, nodeEnv = 'development'): string[] { + const seen = new Set(); + const nodes = value + .split(',') + .map((node) => node.trim()) + .filter(Boolean) + .map((node) => { + const url = new URL(node); + + if (url.username || url.password) { + throw new Error('Hive RPC node URLs must not contain credentials.'); + } + + if (nodeEnv !== 'test' && nodeEnv !== 'development' && url.protocol !== 'https:') { + throw new Error('Hive RPC node URLs must use HTTPS outside local/test environments.'); + } + + url.hash = ''; + + return url.toString().replace(/\/$/, ''); + }) + .filter((node) => { + if (seen.has(node)) { + return false; + } + + seen.add(node); + + return true; + }); + + if (nodes.length === 0) { + throw new Error('At least one Hive RPC node must be configured.'); + } + + return nodes; +} + +export function validateHiveRetryConfig(config: HiveRetryConfig): HiveRetryConfig { + const entries = Object.entries(config); + + for (const [key, value] of entries) { + if (!Number.isFinite(value) || value < 0) { + throw new Error(`Invalid Hive retry configuration for ${key}.`); + } + } + + if (config.maxAttempts < 1) { + throw new Error('HIVE_BROADCAST_MAX_ATTEMPTS must be at least 1.'); + } + + if (config.backoffMultiplier < 1) { + throw new Error('HIVE_BROADCAST_BACKOFF_MULTIPLIER must be at least 1.'); + } + + if (config.initialDelayMs <= 0 || config.maxDelayMs < config.initialDelayMs) { + throw new Error('Hive broadcast delay configuration is invalid.'); + } + + if (config.jitterRatio > 1) { + throw new Error('HIVE_BROADCAST_JITTER_RATIO must be between 0 and 1.'); + } + + if (config.totalDeadlineMs < config.requestTimeoutMs) { + throw new Error('HIVE_BROADCAST_TOTAL_DEADLINE_MS must be >= request timeout.'); + } + + if (config.totalDeadlineMs < config.confirmationTimeoutMs) { + throw new Error('HIVE_BROADCAST_TOTAL_DEADLINE_MS must be >= confirmation timeout.'); + } + + return config; +} + +export function sanitizeHiveNodeUrl(nodeUrl: string): string { + const url = new URL(nodeUrl); + + url.username = ''; + url.password = ''; + url.pathname = ''; + url.search = ''; + url.hash = ''; + + return url.toString().replace(/\/$/, ''); +} + +function validateChainId(chainId: string) { + if (!/^[a-f0-9]{64}$/i.test(chainId)) { + throw new Error('Hive chain ID must be a 64-character hex string.'); + } + + if (chainId.toLowerCase() !== HIVE_MAINNET_CHAIN_ID) { + throw new Error('Hive chain ID must be the mainnet chain ID.'); + } +} diff --git a/apps/api/src/lib/hive/projection.ts b/apps/api/src/lib/hive/projection.ts index 030b0ef..f8cdc40 100644 --- a/apps/api/src/lib/hive/projection.ts +++ b/apps/api/src/lib/hive/projection.ts @@ -41,7 +41,7 @@ export function normalizeHafOperation(row: HafOperationRow): NormalizedHiveOpera 'transaction id', ), operationIndex: requiredNumeric( - row.operation_id ?? row.operationIndex ?? row.op_pos, + row.operation_id ?? row.operationIndex ?? row.op_in_trx ?? row.op_pos, 'operation index', ), blockchainTimestamp: new Date(requiredString(row.timestamp ?? row.created_at, 'timestamp')), @@ -105,6 +105,22 @@ function unwrapHafOperation(row: HafOperationRow): HiveLoreOperation { } } + if (Array.isArray(source) && source.length >= 2) { + const [type, value] = source as [unknown, unknown]; + + if (type === 'comment') { + return { + comment_operation: value as HiveLoreOperation['comment_operation'], + }; + } + + if (type === 'custom_json') { + return { + custom_json_operation: value as HiveLoreOperation['custom_json_operation'], + }; + } + } + if ('comment' in source) { return { comment_operation: (source as { comment: unknown }) diff --git a/apps/api/src/lib/hive/smoke-operation.ts b/apps/api/src/lib/hive/smoke-operation.ts new file mode 100644 index 0000000..9b032f9 --- /dev/null +++ b/apps/api/src/lib/hive/smoke-operation.ts @@ -0,0 +1,117 @@ +import type { custom_json } from '@hiveio/wax'; + +import { HIVE_MAINNET_CHAIN_ID, HIVELORE_SMOKE_CUSTOM_JSON_ID } from './constants.js'; +import { buildHiveNetworkConfig, type HiveNetworkConfig } from './network-config.js'; +import { hiveAccountNameSchema } from './operations.js'; +import type { HiveLoreOperation } from './types.js'; + +export const HIVELORE_MAINNET_SMOKE_PAYLOAD = { + app: 'hivelore', + type: 'mainnet_smoke', + version: 1, + purpose: 'broadcast_readback_verification', +} as const; + +export function buildHiveLoreSmokeCustomJsonOperation(account: string): HiveLoreOperation { + const signer = hiveAccountNameSchema.parse(account); + const operation: custom_json = { + id: HIVELORE_SMOKE_CUSTOM_JSON_ID, + json: JSON.stringify(HIVELORE_MAINNET_SMOKE_PAYLOAD), + required_auths: [], + required_posting_auths: [signer], + }; + + return { + custom_json_operation: operation, + }; +} + +export function verifyHiveLoreSmokeOperation(input: { + operation: HiveLoreOperation; + expectedSigner: string; +}): + | { ok: true; signer: string; payload: typeof HIVELORE_MAINNET_SMOKE_PAYLOAD } + | { + ok: false; + reason: string; + } { + const expectedSigner = hiveAccountNameSchema.parse(input.expectedSigner); + const customJson = input.operation.custom_json_operation; + + if (!customJson) { + return { ok: false, reason: 'Operation is not custom_json.' }; + } + + if (customJson.id !== HIVELORE_SMOKE_CUSTOM_JSON_ID) { + return { ok: false, reason: 'custom_json ID is not hivelore_smoke.' }; + } + + if (customJson.required_auths.length !== 0) { + return { ok: false, reason: 'Smoke custom_json must not require active authority.' }; + } + + if ( + customJson.required_posting_auths.length !== 1 || + customJson.required_posting_auths[0] !== expectedSigner + ) { + return { ok: false, reason: 'Smoke custom_json must require posting authority only.' }; + } + + const payload = parseSmokePayload(customJson.json); + + if (!payload) { + return { ok: false, reason: 'Smoke custom_json payload does not match expected payload.' }; + } + + return { ok: true, payload, signer: expectedSigner }; +} + +export function assertPostingAuthorityOnly(operation: HiveLoreOperation): void { + const customJson = operation.custom_json_operation; + + if (!customJson) { + throw new Error('Smoke operation must be a custom_json operation.'); + } + + if (customJson.required_auths.length > 0) { + throw new Error('BUG: smoke operation requested active authority.'); + } + + if (customJson.required_posting_auths.length !== 1) { + throw new Error('Smoke operation must request exactly one posting authority.'); + } +} + +export function buildMainnetSmokeNetworkFromEnv(environment: NodeJS.ProcessEnv): HiveNetworkConfig { + const network = buildHiveNetworkConfig({ + customJsonId: HIVELORE_SMOKE_CUSTOM_JSON_ID, + mainnetChainId: environment.HIVE_MAINNET_CHAIN_ID, + mainnetHafUrl: environment.HIVE_MAINNET_HAF_URL, + mainnetRpcNodes: environment.HIVE_MAINNET_RPC_NODES, + nodeEnv: environment.NODE_ENV, + }); + + if (network.name !== 'mainnet' || network.chainId !== HIVE_MAINNET_CHAIN_ID) { + throw new Error('Hive smoke test must be pinned to Hive mainnet.'); + } + + if (network.customJsonId !== HIVELORE_SMOKE_CUSTOM_JSON_ID) { + throw new Error('Hive smoke test must use custom_json ID hivelore_smoke.'); + } + + return network; +} + +function parseSmokePayload(value: string): typeof HIVELORE_MAINNET_SMOKE_PAYLOAD | null { + try { + const payload = JSON.parse(value) as typeof HIVELORE_MAINNET_SMOKE_PAYLOAD; + + return payload.type === HIVELORE_MAINNET_SMOKE_PAYLOAD.type && + payload.version === HIVELORE_MAINNET_SMOKE_PAYLOAD.version && + payload.purpose === HIVELORE_MAINNET_SMOKE_PAYLOAD.purpose + ? payload + : null; + } catch { + return null; + } +} diff --git a/apps/api/src/lib/hive/types.ts b/apps/api/src/lib/hive/types.ts index 0ae90a8..d5087c0 100644 --- a/apps/api/src/lib/hive/types.ts +++ b/apps/api/src/lib/hive/types.ts @@ -44,6 +44,13 @@ export interface HiveLoreCustomJsonPayload { payload: Record; } +export interface HiveLoreSmokePayload { + app: 'hivelore'; + type: 'mainnet_smoke'; + version: 1; + purpose: 'broadcast_readback_verification'; +} + export interface BuiltHiveTransaction { transaction: ApiTransaction; binaryHex: string; @@ -66,11 +73,13 @@ export interface HafOperationRow { block_num?: number | string; blockNumber?: number | string; block?: number | string; + transaction_num?: number | string; transaction_id?: string; transactionId?: string; trx_id?: string; operation_id?: number | string; operationIndex?: number | string; + op_in_trx?: number | string; op_pos?: number | string; timestamp?: string; created_at?: string; @@ -103,5 +112,5 @@ export interface HiveOperationVerification { signer?: string; entityType?: HiveLoreEntityType; entityId?: string; - payload?: HiveLoreMetadata | HiveLoreCustomJsonPayload; + payload?: HiveLoreMetadata | HiveLoreCustomJsonPayload | HiveLoreSmokePayload; } diff --git a/apps/api/src/lib/hive/verification.ts b/apps/api/src/lib/hive/verification.ts index eef26ba..47ac434 100644 --- a/apps/api/src/lib/hive/verification.ts +++ b/apps/api/src/lib/hive/verification.ts @@ -1,10 +1,26 @@ import { parseHiveLoreCommentMetadata, parseHiveLoreCustomJsonPayload } from './operations.js'; +import { verifyHiveLoreSmokeOperation } from './smoke-operation.js'; import type { HiveLoreOperation, HiveOperationVerification } from './types.js'; export function verifyHiveLoreOperation(input: { operation: HiveLoreOperation; expectedSigner?: string; }): HiveOperationVerification { + if (input.expectedSigner) { + const smokeVerification = verifyHiveLoreSmokeOperation({ + expectedSigner: input.expectedSigner, + operation: input.operation, + }); + + if (smokeVerification.ok) { + return { + ok: true, + payload: smokeVerification.payload, + signer: smokeVerification.signer, + }; + } + } + const customJsonPayload = parseHiveLoreCustomJsonPayload(input.operation); if (customJsonPayload) { diff --git a/apps/api/src/lib/hive/wax-client.ts b/apps/api/src/lib/hive/wax-client.ts index d134a4c..cea403d 100644 --- a/apps/api/src/lib/hive/wax-client.ts +++ b/apps/api/src/lib/hive/wax-client.ts @@ -42,6 +42,7 @@ export interface HiveWaxClientOptions { apiEndpoint?: string; appName?: string; apiTimeoutMs?: number; + chainId?: string; createChain?: (options: Partial) => Promise; createFoundation?: (options: { chainId: string }) => Promise; } @@ -55,7 +56,7 @@ export class HiveWaxClient { constructor(options: HiveWaxClientOptions = {}) { this.chainOptions = { - chainId: HIVE_MAINNET_CHAIN_ID, + chainId: options.chainId ?? HIVE_MAINNET_CHAIN_ID, apiEndpoint: options.apiEndpoint ?? DEFAULT_HIVE_RPC_URL, waxApiCaller: options.appName ?? DEFAULT_HIVELORE_APP_ID, apiTimeout: options.apiTimeoutMs ?? 10_000, diff --git a/apps/api/src/lib/prisma-conflicts.test.ts b/apps/api/src/lib/prisma-conflicts.test.ts index 37c58bc..9815b8c 100644 --- a/apps/api/src/lib/prisma-conflicts.test.ts +++ b/apps/api/src/lib/prisma-conflicts.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import { mapDuplicateVoteConflict } from './prisma-conflicts.js'; +import { mapDuplicateVoteConflict, mapWorldSlugConflict } from './prisma-conflicts.js'; describe('Prisma conflict mapping', () => { test('maps duplicate proposal vote unique constraint to 409', () => { @@ -29,6 +29,31 @@ describe('Prisma conflict mapping', () => { assert.equal(conflict, null); }); + test('maps only world slug unique conflicts to 409', () => { + assert.deepEqual( + mapWorldSlugConflict({ + code: 'P2002', + meta: { + target: ['slug'], + }, + }), + { + statusCode: 409, + error: 'World slug is already in use.', + }, + ); + + assert.equal( + mapWorldSlugConflict({ + code: 'P2002', + meta: { + target: ['proposalId', 'voterId'], + }, + }), + null, + ); + }); + test('does not map non-unique Prisma errors', () => { const conflict = mapDuplicateVoteConflict({ code: 'P2025', diff --git a/apps/api/src/lib/prisma-conflicts.ts b/apps/api/src/lib/prisma-conflicts.ts index 8f29bdb..57a7e18 100644 --- a/apps/api/src/lib/prisma-conflicts.ts +++ b/apps/api/src/lib/prisma-conflicts.ts @@ -14,6 +14,14 @@ function hasDuplicateVoteTarget(target: unknown) { return Array.isArray(target) && target.includes('proposalId') && target.includes('voterId'); } +function hasWorldSlugTarget(target: unknown) { + if (Array.isArray(target)) { + return target.length === 1 && target.includes('slug'); + } + + return target === 'World_slug_key'; +} + export function mapDuplicateVoteConflict(error: unknown): ConflictHttpError | null { const knownError = error as PrismaKnownRequestErrorLike; @@ -26,3 +34,16 @@ export function mapDuplicateVoteConflict(error: unknown): ConflictHttpError | nu error: 'User has already voted on this proposal.', }; } + +export function mapWorldSlugConflict(error: unknown): ConflictHttpError | null { + const knownError = error as PrismaKnownRequestErrorLike; + + if (knownError.code !== 'P2002' || !hasWorldSlugTarget(knownError.meta?.target)) { + return null; + } + + return { + statusCode: 409, + error: 'World slug is already in use.', + }; +} diff --git a/apps/api/src/lib/proposal-comments.ts b/apps/api/src/lib/proposal-comments.ts new file mode 100644 index 0000000..092aec7 --- /dev/null +++ b/apps/api/src/lib/proposal-comments.ts @@ -0,0 +1,252 @@ +import type { Prisma, PrismaClient } from '../generated/prisma/client.js'; + +export const PROPOSAL_COMMENT_MAX_LENGTH = 3000; +export const PROPOSAL_COMMENT_DEFAULT_PAGE_SIZE = 20; +export const PROPOSAL_COMMENT_MAX_PAGE_SIZE = 50; + +export class ProposalCommentError extends Error { + constructor( + public readonly statusCode: number, + public readonly code: string, + message: string, + ) { + super(message); + } +} + +export type ProposalCommentDatabase = PrismaClient; + +type CommentCursor = { + createdAt: string; + id: string; +}; + +type CommentWithAuthor = Prisma.ProposalCommentGetPayload<{ + include: { + author: { + select: { + avatarUrl: true; + displayName: true; + hiveUsername: true; + id: true; + }; + }; + }; +}>; + +function encodeCursor(cursor: CommentCursor) { + return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url'); +} + +export function decodeProposalCommentCursor(cursor: string): CommentCursor { + try { + const decoded = JSON.parse( + Buffer.from(cursor, 'base64url').toString('utf8'), + ) as Partial; + + if ( + typeof decoded.id !== 'string' || + !decoded.id || + typeof decoded.createdAt !== 'string' || + Number.isNaN(Date.parse(decoded.createdAt)) + ) { + throw new Error('Invalid cursor payload.'); + } + + return { + createdAt: decoded.createdAt, + id: decoded.id, + }; + } catch { + throw new ProposalCommentError(400, 'INVALID_COMMENT_CURSOR', 'Invalid comment cursor.'); + } +} + +export function normalizeProposalCommentBody(body: string) { + const normalized = body.trim(); + + if (!normalized) { + throw new ProposalCommentError(400, 'COMMENT_BODY_EMPTY', 'Comment body is required.'); + } + + if (normalized.length > PROPOSAL_COMMENT_MAX_LENGTH) { + throw new ProposalCommentError( + 400, + 'COMMENT_BODY_TOO_LONG', + `Comment body must be ${PROPOSAL_COMMENT_MAX_LENGTH} characters or fewer.`, + ); + } + + return normalized; +} + +function normalizePageSize(pageSize: number | undefined) { + if (!pageSize) { + return PROPOSAL_COMMENT_DEFAULT_PAGE_SIZE; + } + + return Math.min(pageSize, PROPOSAL_COMMENT_MAX_PAGE_SIZE); +} + +function serializeComment(comment: CommentWithAuthor) { + const isDeleted = Boolean(comment.deletedAt); + + return { + author: { + avatarUrl: comment.author.avatarUrl, + displayName: comment.author.displayName, + hiveUsername: comment.author.hiveUsername, + id: comment.author.id, + }, + authorId: comment.authorId, + body: isDeleted ? null : comment.body, + createdAt: comment.createdAt.toISOString(), + deletedAt: comment.deletedAt?.toISOString() ?? null, + id: comment.id, + isDeleted, + proposalId: comment.proposalId, + }; +} + +function proposalCommentVisibilityWhere(input: { + proposalId: string; +}): Prisma.ProposalCommentWhereInput { + return { + proposalId: input.proposalId, + }; +} + +function proposalCommentCursorWhere( + cursor: CommentCursor | null, +): Prisma.ProposalCommentWhereInput { + if (!cursor) { + return {}; + } + + const cursorDate = new Date(cursor.createdAt); + + return { + OR: [ + { + createdAt: { + gt: cursorDate, + }, + }, + { + createdAt: cursorDate, + id: { + gt: cursor.id, + }, + }, + ], + }; +} + +async function assertProposalInWorld( + database: ProposalCommentDatabase, + input: { proposalId: string; worldId: string }, +) { + const proposal = await database.proposal.findFirst({ + select: { + id: true, + }, + where: { + id: input.proposalId, + worldId: input.worldId, + }, + }); + + if (!proposal) { + throw new ProposalCommentError(404, 'PROPOSAL_NOT_FOUND', 'Proposal not found.'); + } +} + +export async function listProposalComments( + database: ProposalCommentDatabase, + input: { + cursor?: string | undefined; + pageSize?: number | undefined; + proposalId: string; + worldId: string; + }, +) { + await assertProposalInWorld(database, input); + + const pageSize = normalizePageSize(input.pageSize); + const cursor = input.cursor ? decodeProposalCommentCursor(input.cursor) : null; + const visibilityWhere = proposalCommentVisibilityWhere({ proposalId: input.proposalId }); + const pageWhere: Prisma.ProposalCommentWhereInput = { + ...visibilityWhere, + ...proposalCommentCursorWhere(cursor), + }; + const [comments, commentCount] = await Promise.all([ + database.proposalComment.findMany({ + include: { + author: { + select: { + avatarUrl: true, + displayName: true, + hiveUsername: true, + id: true, + }, + }, + }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + take: pageSize + 1, + where: pageWhere, + }), + database.proposalComment.count({ + where: visibilityWhere, + }), + ]); + + const page = comments.slice(0, pageSize); + const last = page.at(-1); + const hasMore = comments.length > pageSize; + + return { + comments: page.map(serializeComment), + pageInfo: { + hasMore, + nextCursor: + hasMore && last + ? encodeCursor({ + createdAt: last.createdAt.toISOString(), + id: last.id, + }) + : null, + }, + totalCount: commentCount, + }; +} + +export async function createProposalComment( + database: ProposalCommentDatabase, + input: { authorId: string; body: string; proposalId: string; worldId: string }, +) { + const body = normalizeProposalCommentBody(input.body); + + await assertProposalInWorld(database, input); + + const comment = await database.proposalComment.create({ + data: { + authorId: input.authorId, + body, + proposalId: input.proposalId, + }, + include: { + author: { + select: { + avatarUrl: true, + displayName: true, + hiveUsername: true, + id: true, + }, + }, + }, + }); + + return { + comment: serializeComment(comment), + }; +} diff --git a/apps/api/src/lib/world-permissions.test.ts b/apps/api/src/lib/world-permissions.test.ts index ef25b11..ef940d3 100644 --- a/apps/api/src/lib/world-permissions.test.ts +++ b/apps/api/src/lib/world-permissions.test.ts @@ -28,6 +28,7 @@ const EXPECTED_ROLE_PERMISSIONS = { WORLD_PERMISSIONS.EDIT_OWN_DRAFT, WORLD_PERMISSIONS.EDIT_INITIAL_CANON, WORLD_PERMISSIONS.VOTE_ON_PROPOSAL, + WORLD_PERMISSIONS.EXECUTE_CANON_STATUS_AFTER_THRESHOLD, WORLD_PERMISSIONS.REPORT_SPAM_ABUSE, WORLD_PERMISSIONS.COMMENT_ON_AI_WARNING, ], diff --git a/apps/api/src/lib/world-permissions.ts b/apps/api/src/lib/world-permissions.ts index 4e13365..3ee0a8f 100644 --- a/apps/api/src/lib/world-permissions.ts +++ b/apps/api/src/lib/world-permissions.ts @@ -46,6 +46,7 @@ export const WORLD_ROLE_PERMISSIONS = { WORLD_PERMISSIONS.EDIT_OWN_DRAFT, WORLD_PERMISSIONS.EDIT_INITIAL_CANON, WORLD_PERMISSIONS.VOTE_ON_PROPOSAL, + WORLD_PERMISSIONS.EXECUTE_CANON_STATUS_AFTER_THRESHOLD, WORLD_PERMISSIONS.REPORT_SPAM_ABUSE, WORLD_PERMISSIONS.COMMENT_ON_AI_WARNING, ], diff --git a/apps/api/src/lib/worlds.ts b/apps/api/src/lib/worlds.ts index a6bfcc1..685e758 100644 --- a/apps/api/src/lib/worlds.ts +++ b/apps/api/src/lib/worlds.ts @@ -5,6 +5,7 @@ import { WorldAuditAction, WorldRole, } from '../generated/prisma/enums.js'; +import { mapWorldSlugConflict } from './prisma-conflicts.js'; export type WorldSeedInput = { premise: string; @@ -32,6 +33,7 @@ export type CreateWorldInput = { export type UpdateWorldInput = { worldId: string; + actorId: string; title?: string | undefined; description?: string | undefined; seed?: Partial | undefined; @@ -43,6 +45,7 @@ export type WorldDatabase = Pick< | '$transaction' | 'loreEntry' | 'proposal' + | 'refreshSession' | 'world' | 'worldAuditLog' | 'worldBibleVersion' @@ -50,6 +53,15 @@ export type WorldDatabase = Pick< | 'worldSeed' >; +export class WorldError extends Error { + constructor( + public readonly statusCode: number, + message: string, + ) { + super(message); + } +} + const worldInclude = { bibleVersions: { orderBy: { @@ -69,10 +81,43 @@ const worldInclude = { seed: true, } as const; +const worldListInclude = { + bibleVersions: { + orderBy: { + versionNumber: 'desc', + }, + select: { + changeSummary: true, + createdAt: true, + creatorId: true, + hiveReferenceId: true, + id: true, + publishedAt: true, + updatedAt: true, + versionNumber: true, + }, + take: 1, + }, + founder: { + select: { + avatarUrl: true, + displayName: true, + hiveUsername: true, + id: true, + normalizedHiveUsername: true, + }, + }, + seed: true, +} as const; + type WorldWithRelations = Prisma.WorldGetPayload<{ include: typeof worldInclude; }>; +type WorldListItemWithRelations = Prisma.WorldGetPayload<{ + include: typeof worldListInclude; +}>; + export function createSlug(title: string) { const slug = title .trim() @@ -113,14 +158,19 @@ function serializeSeed(seed: NonNullable) { }; } -function serializeBibleVersion(version: WorldWithRelations['bibleVersions'][number] | null) { +function serializeBibleVersion( + version: + | WorldWithRelations['bibleVersions'][number] + | WorldListItemWithRelations['bibleVersions'][number] + | null, +) { if (!version) { return null; } return { changeSummary: version.changeSummary, - content: version.content, + ...('content' in version ? { content: version.content } : {}), createdAt: version.createdAt.toISOString(), creatorId: version.creatorId, hiveReferenceId: version.hiveReferenceId, @@ -148,6 +198,21 @@ export function serializeWorld(world: WorldWithRelations) { export type SerializedWorld = ReturnType; +export function serializeWorldListItem(world: WorldListItemWithRelations) { + return { + createdAt: world.createdAt.toISOString(), + currentBibleVersion: serializeBibleVersion(world.bibleVersions[0] ?? null), + description: world.description, + founder: world.founder, + founderId: world.founderId, + id: world.id, + seed: world.seed ? serializeSeed(world.seed) : null, + slug: world.slug, + title: world.title, + updatedAt: world.updatedAt.toISOString(), + }; +} + async function generateUniqueWorldSlug(database: Pick, title: string) { const baseSlug = createSlug(title); let slug = baseSlug; @@ -169,78 +234,88 @@ async function generateUniqueWorldSlug(database: Pick, t slug = `${baseSlug}-${suffix}`; } - throw new Error('Unable to create a unique world slug.'); + throw new WorldError(409, 'Unable to create a unique world slug.'); } export async function createWorld(database: WorldDatabase, input: CreateWorldInput) { - const slug = await generateUniqueWorldSlug(database, input.title); const seed = normalizeSeedInput(input.seed); - const world = await database.$transaction(async (transaction) => { - const createdWorld = await transaction.world.create({ - data: { - description: input.description.trim(), - founderId: input.creatorId, - slug, - title: input.title.trim(), - }, - }); - - await transaction.worldSeed.create({ - data: { - firstCharacters: seed.firstCharacters, - firstFactions: seed.firstFactions, - firstHistoricalEvent: seed.firstHistoricalEvent, - genre: seed.genre, - mainConflict: seed.mainConflict, - premise: seed.premise, - startingLocation: seed.startingLocation, - tone: seed.tone, - worldId: createdWorld.id, - }, - }); + try { + const world = await database.$transaction(async (transaction: Prisma.TransactionClient) => { + const slug = await generateUniqueWorldSlug(transaction, input.title); + const createdWorld = await transaction.world.create({ + data: { + description: input.description.trim(), + founderId: input.creatorId, + slug, + title: input.title.trim(), + }, + }); - await transaction.worldBibleVersion.create({ - data: { - changeSummary: input.bible.changeSummary?.trim() || 'Initial world bible.', - content: input.bible.content, - creatorId: input.creatorId, - versionNumber: 1, - worldId: createdWorld.id, - }, - }); + await transaction.worldSeed.create({ + data: { + firstCharacters: seed.firstCharacters, + firstFactions: seed.firstFactions, + firstHistoricalEvent: seed.firstHistoricalEvent, + genre: seed.genre, + mainConflict: seed.mainConflict, + premise: seed.premise, + startingLocation: seed.startingLocation, + tone: seed.tone, + worldId: createdWorld.id, + }, + }); - const membership = await transaction.worldMembership.create({ - data: { - grantedById: input.creatorId, - role: WorldRole.FOUNDER, - userId: input.creatorId, - worldId: createdWorld.id, - }, - }); + await transaction.worldBibleVersion.create({ + data: { + changeSummary: input.bible.changeSummary?.trim() || 'Initial world bible.', + content: input.bible.content, + creatorId: input.creatorId, + versionNumber: 1, + worldId: createdWorld.id, + }, + }); - await transaction.worldAuditLog.create({ - data: { - action: WorldAuditAction.ROLE_ASSIGNED, - actorId: input.creatorId, - metadata: { + const membership = await transaction.worldMembership.create({ + data: { + grantedById: input.creatorId, role: WorldRole.FOUNDER, + userId: input.creatorId, + worldId: createdWorld.id, }, - targetId: membership.id, - targetType: 'WORLD_MEMBERSHIP', - worldId: createdWorld.id, - }, - }); + }); - return transaction.world.findUniqueOrThrow({ - include: worldInclude, - where: { - id: createdWorld.id, - }, + await transaction.worldAuditLog.create({ + data: { + action: WorldAuditAction.ROLE_ASSIGNED, + actorId: input.creatorId, + metadata: { + role: WorldRole.FOUNDER, + }, + targetId: membership.id, + targetType: 'WORLD_MEMBERSHIP', + worldId: createdWorld.id, + }, + }); + + return transaction.world.findUniqueOrThrow({ + include: worldInclude, + where: { + id: createdWorld.id, + }, + }); }); - }); - return serializeWorld(world); + return serializeWorld(world); + } catch (error) { + const conflict = mapWorldSlugConflict(error); + + if (conflict) { + throw new WorldError(conflict.statusCode, conflict.error); + } + + throw error; + } } export async function listWorlds( @@ -297,7 +372,7 @@ export async function listWorlds( const [worlds, total] = await Promise.all([ database.world.findMany({ - include: worldInclude, + include: worldListInclude, orderBy: [ { createdAt: 'desc', @@ -321,7 +396,7 @@ export async function listWorlds( pageSize, total, }, - worlds: worlds.map(serializeWorld), + worlds: worlds.map(serializeWorldListItem), }; } @@ -384,7 +459,7 @@ export async function getWorldHub(database: WorldDatabase, worldId: string) { ]); return { - latestLoreEntries: latestLoreEntries.map((entry) => ({ + latestLoreEntries: latestLoreEntries.map((entry: { updatedAt: Date }) => ({ ...entry, updatedAt: entry.updatedAt.toISOString(), })), @@ -397,7 +472,7 @@ export async function getWorldHub(database: WorldDatabase, worldId: string) { } export async function updateWorld(database: WorldDatabase, input: UpdateWorldInput) { - const world = await database.$transaction(async (transaction) => { + const world = await database.$transaction(async (transaction: Prisma.TransactionClient) => { const current = await transaction.world.findUnique({ include: { bibleVersions: { @@ -414,7 +489,7 @@ export async function updateWorld(database: WorldDatabase, input: UpdateWorldInp }); if (!current) { - return null; + throw new WorldError(404, 'World not found.'); } if (input.title || input.description) { @@ -433,7 +508,7 @@ export async function updateWorld(database: WorldDatabase, input: UpdateWorldInp const currentSeed = current.seed; if (!currentSeed) { - throw new Error('World seed is missing.'); + throw new WorldError(409, 'World seed is missing.'); } const normalizedSeed = normalizeSeedInput({ @@ -441,14 +516,14 @@ export async function updateWorld(database: WorldDatabase, input: UpdateWorldInp input.seed.firstCharacters ?? (Array.isArray(currentSeed.firstCharacters) ? currentSeed.firstCharacters.filter( - (value): value is string => typeof value === 'string', + (value: unknown): value is string => typeof value === 'string', ) : []), firstFactions: input.seed.firstFactions ?? (Array.isArray(currentSeed.firstFactions) ? currentSeed.firstFactions.filter( - (value): value is string => typeof value === 'string', + (value: unknown): value is string => typeof value === 'string', ) : []), firstHistoricalEvent: @@ -472,7 +547,11 @@ export async function updateWorld(database: WorldDatabase, input: UpdateWorldInp const currentBible = current.bibleVersions[0]; if (!currentBible) { - throw new Error('World bible version is missing.'); + throw new WorldError(409, 'World bible version is missing.'); + } + + if (currentBible.publishedAt || currentBible.hiveReferenceId) { + throw new WorldError(409, 'Published world bible versions cannot be edited in place.'); } await transaction.worldBibleVersion.update({ @@ -484,6 +563,20 @@ export async function updateWorld(database: WorldDatabase, input: UpdateWorldInp id: currentBible.id, }, }); + + await transaction.worldAuditLog.create({ + data: { + action: WorldAuditAction.WORLD_BIBLE_UPDATED, + actorId: input.actorId, + metadata: { + changeSummary: input.bible.changeSummary?.trim() || currentBible.changeSummary, + versionNumber: currentBible.versionNumber, + }, + targetId: currentBible.id, + targetType: 'WORLD_BIBLE_VERSION', + worldId: input.worldId, + }, + }); } return transaction.world.findUniqueOrThrow({ @@ -494,5 +587,5 @@ export async function updateWorld(database: WorldDatabase, input: UpdateWorldInp }); }); - return world ? serializeWorld(world) : null; + return serializeWorld(world); } diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 90f5efa..e37661a 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyRequest } from 'fastify'; import { z } from 'zod'; import { env } from '../config/env.js'; +import type { Prisma } from '../generated/prisma/client.js'; import { prisma } from '../lib/prisma.js'; import { createAuthChallenge, @@ -209,26 +210,28 @@ export async function registerAuthRoutes( }); } - const issuedSession = await database.$transaction(async (transaction) => { - await consumeAuthChallenge(transaction, { - challengeId: parsed.data.challengeId, - hmacSecret: env.AUTH_JWT_SECRET, - message: parsed.data.message, - provider: parsed.data.provider, - username: normalizedHiveUsername, - }); - - const user = await upsertHiveUser(transaction, { - normalizedHiveUsername, - }); - - return issueSession(transaction, { - ...sessionOptions(), - ipAddress: request.ip, - user, - userAgent: request.headers['user-agent'], - }); - }); + const issuedSession = await database.$transaction( + async (transaction: Prisma.TransactionClient) => { + await consumeAuthChallenge(transaction, { + challengeId: parsed.data.challengeId, + hmacSecret: env.AUTH_JWT_SECRET, + message: parsed.data.message, + provider: parsed.data.provider, + username: normalizedHiveUsername, + }); + + const user = await upsertHiveUser(transaction, { + normalizedHiveUsername, + }); + + return issueSession(transaction, { + ...sessionOptions(), + ipAddress: request.ip, + user, + userAgent: request.headers['user-agent'], + }); + }, + ); setRefreshCookie(reply, issuedSession.refreshToken, { ...cookieOptions(), diff --git a/apps/api/src/routes/contributions.test.ts b/apps/api/src/routes/contributions.test.ts index c922f38..c5b455c 100644 --- a/apps/api/src/routes/contributions.test.ts +++ b/apps/api/src/routes/contributions.test.ts @@ -419,6 +419,9 @@ function createDatabase() { }, refreshSession: { async findUnique(args: { where: { id: string } }) { + if (!args.where.id.startsWith('session-')) { + return null; + } const userId = args.where.id.replace(/^session-/, ''); return { @@ -433,6 +436,17 @@ function createDatabase() { return worlds.find((world) => world.id === args.where.id) ?? null; }, }, + worldBibleVersion: { + async findFirst(args: { where: { worldId: string } }) { + if (!worlds.some((world) => world.id === args.where.worldId)) { + return null; + } + + return { + id: `bible-${args.where.worldId}`, + }; + }, + }, worldAuditLog: { async create(args: { data: unknown }) { if (failNextAuditCreate) { @@ -826,7 +840,7 @@ describe('contribution routes', () => { assert.equal(state.contributions[0]?.status, ContributionStatus.SUBMITTED); assert.equal(state.contributions[0]?.proposalId, 'proposal-1'); assert.equal(submitResponse.json().proposal.authorId, author.id); - assert.equal(submitResponse.json().proposal.status, ProposalStatus.SUBMITTED); + assert.equal(submitResponse.json().proposal.status, ProposalStatus.VOTING); assert.equal(submitResponse.json().proposal.proposalType, ProposalType.UPDATE_LORE); assert.deepEqual(submitResponse.json().proposal.proposedContent, structuredDoc); assert.equal( diff --git a/apps/api/src/routes/proposals.test.ts b/apps/api/src/routes/proposals.test.ts new file mode 100644 index 0000000..d3e1548 --- /dev/null +++ b/apps/api/src/routes/proposals.test.ts @@ -0,0 +1,918 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import Fastify from 'fastify'; +import rateLimit from '@fastify/rate-limit'; + +import { env } from '../config/env.js'; +import { + PlatformRole, + ProposalDecisionOutcome, + ProposalStatus, + WorldAuditAction, + WorldRole, +} from '../generated/prisma/enums.js'; +import { signAccessToken } from '../lib/auth-crypto.js'; +import { hashCanonicalJson } from '../lib/canon-voting-policy.js'; +import { HIVELORE_CUSTOM_JSON_ID } from '../lib/hive/constants.js'; +import { buildHiveLoreCustomJsonOperation } from '../lib/hive/operations.js'; +import { PROPOSAL_COMMENT_MAX_LENGTH } from '../lib/proposal-comments.js'; +import { registerProposalRoutes } from './proposals.js'; + +const author = { + hiveUsername: 'mira-vale.dev', + id: 'user-author', + normalizedHiveUsername: 'mira-vale.dev', +}; + +const reader = { + hiveUsername: 'emberquill.dev', + id: 'user-reader', + normalizedHiveUsername: 'emberquill.dev', +}; + +const outsider = { + hiveUsername: 'stranger.dev', + id: 'user-outsider', + normalizedHiveUsername: 'stranger.dev', +}; + +function authHeader(user = reader) { + const token = signAccessToken( + { + hiveUsername: user.hiveUsername, + normalizedHiveUsername: user.normalizedHiveUsername, + platformRole: PlatformRole.USER, + sid: `session-${user.id}`, + sub: user.id, + }, + { + audience: env.AUTH_JWT_AUDIENCE, + issuer: env.AUTH_JWT_ISSUER, + secret: env.AUTH_JWT_SECRET, + ttlSeconds: 900, + }, + ); + + return { + authorization: `Bearer ${token}`, + }; +} + +function now(offsetMs = 0) { + return new Date(new Date('2026-08-10T12:00:00.000Z').getTime() + offsetMs); +} + +const openVotingEndsAt = new Date('2099-08-12T12:00:00.000Z'); + +type StoredComment = { + authorId: string; + body: string; + createdAt: Date; + deletedAt: Date | null; + deletedById: string | null; + id: string; + proposalId: string; +}; + +function encodeCursor(input: { createdAt: string; id: string }) { + return Buffer.from(JSON.stringify(input), 'utf8').toString('base64url'); +} + +const decisionPayload = { + counts: { + alternateTimeline: 0, + approve: 7, + needsRevision: 0, + reject: 3, + total: 10, + }, + eventType: 'canon_decision', + outcome: ProposalDecisionOutcome.APPROVED_FOR_PUBLICATION, + proposalId: 'proposal-1', + worldId: 'world-1', +}; + +function createDecision() { + return { + aiWarningAcknowledged: false, + aiWarningSummary: null, + alternateTimelineCount: 0, + approvalDenominator: 10, + approvalNumerator: 7, + approvalPercentageBps: 7000, + approvalThresholdBps: 7000, + approveCount: 7, + blockchainTimestamp: null, + blockNumber: null, + contentHash: 'content-hash', + createdAt: now(), + customJsonId: HIVELORE_CUSTOM_JSON_ID, + decidedAt: now(), + decisionPayload, + decisionPayloadHash: hashCanonicalJson(decisionPayload), + expectedSigner: 'mira-vale.dev', + hiveEventId: null, + id: 'decision-1', + minimumVotes: 5, + needsRevisionCount: 0, + operationIndex: null, + outcome: ProposalDecisionOutcome.APPROVED_FOR_PUBLICATION, + rejectCount: 3, + rulesVersion: 'canon-voting-mvp-2026-08-12', + totalVotes: 10, + transactionId: null, + updatedAt: now(), + votingWindowHours: 48, + }; +} + +function createConfirmedOperation(transactionId = 'tx-valid') { + return { + blockNumber: BigInt(100), + blockchainTimestamp: new Date('2026-08-12T12:05:00.000Z'), + operation: buildHiveLoreCustomJsonOperation({ + action: 'canon_approval', + entityId: 'decision-1', + entityType: 'CANON_DECISION', + payload: decisionPayload, + proposalId: 'proposal-1', + signer: 'mira-vale.dev', + worldId: 'world-1', + }), + operationIndex: 0, + transactionId, + }; +} + +function createDatabase() { + const decision = createDecision(); + const users = [ + { ...author, avatarUrl: null, displayName: 'Mira Vale' }, + { ...reader, avatarUrl: null, displayName: null }, + { ...outsider, avatarUrl: null, displayName: null }, + ]; + const proposals = [ + { + author: { + hiveUsername: author.hiveUsername, + id: author.id, + }, + authorId: author.id, + decision: null, + id: 'proposal-1', + status: ProposalStatus.VOTING, + votingEndsAt: openVotingEndsAt, + worldId: 'world-1', + }, + { + author: { + hiveUsername: author.hiveUsername, + id: author.id, + }, + authorId: author.id, + decision: null, + id: 'proposal-closed', + status: ProposalStatus.REJECTED, + votingEndsAt: now(-1), + worldId: 'world-1', + }, + { + author: { + hiveUsername: author.hiveUsername, + id: author.id, + }, + authorId: author.id, + decision: null, + id: 'proposal-same-world', + status: ProposalStatus.VOTING, + votingEndsAt: openVotingEndsAt, + worldId: 'world-1', + }, + { + author: { + hiveUsername: author.hiveUsername, + id: author.id, + }, + authorId: author.id, + decision: null, + id: 'proposal-2', + status: ProposalStatus.VOTING, + votingEndsAt: openVotingEndsAt, + worldId: 'world-2', + }, + ]; + const memberships = [ + { + id: 'membership-reader', + revokedAt: null, + role: WorldRole.READER, + userId: reader.id, + worldId: 'world-1', + }, + { + id: 'membership-author', + revokedAt: null, + role: WorldRole.CONTRIBUTOR, + userId: author.id, + worldId: 'world-1', + }, + ]; + const comments: StoredComment[] = []; + const appVotes: unknown[] = []; + const auditLogs: unknown[] = []; + const events: unknown[] = []; + const touchedVotes: string[] = []; + + function includeAuthor(comment: StoredComment) { + const user = users.find((candidate) => candidate.id === comment.authorId)!; + + return { + ...comment, + author: { + avatarUrl: user.avatarUrl, + displayName: user.displayName, + hiveUsername: user.hiveUsername, + id: user.id, + }, + }; + } + + const database = { + appVote: { + async create() { + touchedVotes.push('create'); + }, + async update() { + touchedVotes.push('update'); + }, + async upsert(args: { create: { choice: string; proposalId: string; voterId: string } }) { + touchedVotes.push('upsert'); + const vote = { + ...args.create, + createdAt: now(), + id: `vote-${touchedVotes.length}`, + updatedAt: now(), + }; + appVotes.push(vote); + return vote; + }, + }, + proposal: { + async findFirst(args: { where: { id: string; worldId: string } }) { + return ( + proposals.find( + (proposal) => proposal.id === args.where.id && proposal.worldId === args.where.worldId, + ) ?? null + ); + }, + }, + proposalComment: { + async count(args: { where: { deletedAt?: null; proposalId: string } }) { + return comments.filter( + (comment) => + comment.proposalId === args.where.proposalId && + (!('deletedAt' in args.where) || comment.deletedAt === args.where.deletedAt), + ).length; + }, + async create(args: { data: { authorId: string; body: string; proposalId: string } }) { + const comment = { + ...args.data, + createdAt: now(comments.length), + deletedAt: null, + deletedById: null, + id: `comment-${comments.length + 1}`, + }; + comments.push(comment); + return includeAuthor(comment); + }, + async findMany(args: { + orderBy: unknown; + take: number; + where: { + OR?: Array<{ + createdAt: Date | { gt: Date }; + id?: { gt: string }; + }>; + proposalId: string; + }; + }) { + return comments + .filter((comment) => comment.proposalId === args.where.proposalId) + .filter((comment) => { + if (!args.where.OR) { + return true; + } + + return args.where.OR.some((condition) => { + if ( + condition.createdAt instanceof Date && + condition.id && + comment.createdAt.getTime() === condition.createdAt.getTime() + ) { + return comment.id > condition.id.gt; + } + + if ( + typeof condition.createdAt === 'object' && + 'gt' in condition.createdAt && + condition.createdAt.gt instanceof Date + ) { + return comment.createdAt.getTime() > condition.createdAt.gt.getTime(); + } + + return false; + }); + }) + .sort((left, right) => { + const createdAtOrder = left.createdAt.getTime() - right.createdAt.getTime(); + + if (createdAtOrder !== 0) { + return createdAtOrder; + } + + return left.id.localeCompare(right.id); + }) + .slice(0, args.take) + .map(includeAuthor); + }, + }, + proposalDecision: { + async findUnique(args: { where: { id: string } }) { + return args.where.id === decision.id ? decision : null; + }, + async update(args: { data: Record }) { + Object.assign(decision, args.data); + return decision; + }, + async updateMany(args: { + data: Record; + where: { + hiveEventId: null; + id: string; + operationIndex: null; + transactionId: null; + }; + }) { + if ( + args.where.id === decision.id && + decision.hiveEventId === null && + decision.operationIndex === null && + decision.transactionId === null + ) { + Object.assign(decision, args.data); + return { count: 1 }; + } + + return { count: 0 }; + }, + }, + hiveEvent: { + async upsert(args: { + create: { operationIndex: number; transactionId: string }; + update: Record; + where: { transactionId_operationIndex: { operationIndex: number; transactionId: string } }; + }) { + const existing = events.find( + (event) => + (event as { operationIndex: number; transactionId: string }).operationIndex === + args.where.transactionId_operationIndex.operationIndex && + (event as { operationIndex: number; transactionId: string }).transactionId === + args.where.transactionId_operationIndex.transactionId, + ); + + if (existing) { + Object.assign(existing, args.update); + return existing; + } + + const event = { + id: `event-${events.length + 1}`, + ...args.create, + }; + events.push(event); + return event; + }, + }, + refreshSession: { + async findUnique(args: { where: { id: string } }) { + if (!args.where.id.startsWith('session-')) { + return null; + } + + return { + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + revokedAt: null, + userId: args.where.id.replace('session-', ''), + }; + }, + }, + worldMembership: { + async findUnique(args: { + where: { worldId_userId: { userId: string; worldId: string }; revokedAt: null }; + }) { + return ( + memberships.find( + (membership) => + membership.userId === args.where.worldId_userId.userId && + membership.worldId === args.where.worldId_userId.worldId && + !membership.revokedAt, + ) ?? null + ); + }, + }, + worldAuditLog: { + async create(args: { data: unknown }) { + auditLogs.push(args.data); + return args.data; + }, + }, + $transaction(callback: (transaction: unknown) => Promise) { + return callback(database); + }, + }; + + return { + appVotes, + auditLogs, + comments, + database, + decision, + events, + memberships, + proposals, + touchedVotes, + }; +} + +async function createApp( + database: ReturnType['database'], + options: Parameters[1] = {}, +) { + const app = Fastify(); + await app.register(rateLimit, { + global: false, + }); + await registerProposalRoutes(app, { + database: database as never, + ...options, + }); + return app; +} + +function attachConfirmedDecision(state: ReturnType) { + (state.proposals[0] as { decision: ReturnType | null }).decision = + state.decision; +} + +describe('proposal comment routes', () => { + test('authenticated eligible user can create a trimmed comment without creating votes or trusting protected fields', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + + const protectedFieldResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + authorId: author.id, + body: 'Nope', + createdAt: '2026-08-10T00:00:00.000Z', + role: WorldRole.CURATOR, + }, + url: '/worlds/world-1/proposals/proposal-1/comments', + }); + const response = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + body: ' \nUseful note. ', + }, + url: '/worlds/world-1/proposals/proposal-1/comments', + }); + + assert.equal(protectedFieldResponse.statusCode, 400); + assert.equal(response.statusCode, 201); + assert.equal(response.json().comment.authorId, reader.id); + assert.equal(response.json().comment.body, '\nUseful note.'); + assert.equal(state.comments.length, 1); + assert.equal(state.comments[0]?.proposalId, 'proposal-1'); + assert.equal(state.comments[0]?.authorId, reader.id); + assert.equal(state.touchedVotes.length, 0); + assert.equal(state.appVotes.length, 0); + await app.close(); + }); + + test('rejects unauthenticated, unauthorized, empty, whitespace, oversized, unknown, and world-mismatched comment writes', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + + const unauthenticated = await app.inject({ + method: 'POST', + payload: { body: 'Hello' }, + url: '/worlds/world-1/proposals/proposal-1/comments', + }); + const forbidden = await app.inject({ + headers: authHeader(outsider), + method: 'POST', + payload: { body: 'Hello' }, + url: '/worlds/world-1/proposals/proposal-1/comments', + }); + const empty = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { body: '' }, + url: '/worlds/world-1/proposals/proposal-1/comments', + }); + const whitespace = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { body: ' \n\t' }, + url: '/worlds/world-1/proposals/proposal-1/comments', + }); + const maxLength = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { body: 'x'.repeat(PROPOSAL_COMMENT_MAX_LENGTH) }, + url: '/worlds/world-1/proposals/proposal-1/comments', + }); + const oversized = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { body: 'x'.repeat(PROPOSAL_COMMENT_MAX_LENGTH + 1) }, + url: '/worlds/world-1/proposals/proposal-1/comments', + }); + const missingProposal = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { body: 'Hello' }, + url: '/worlds/world-1/proposals/missing/comments', + }); + const wrongWorld = await app.inject({ + headers: authHeader(author), + method: 'POST', + payload: { body: 'Hello' }, + url: '/worlds/world-2/proposals/proposal-1/comments', + }); + + assert.equal(unauthenticated.statusCode, 401); + assert.equal(forbidden.statusCode, 403); + assert.equal(empty.statusCode, 400); + assert.equal(whitespace.statusCode, 400); + assert.equal(maxLength.statusCode, 201); + assert.equal(oversized.statusCode, 400); + assert.equal(missingProposal.statusCode, 404); + assert.equal(wrongWorld.statusCode, 403, wrongWorld.body); + assert.equal(state.comments.length, 1); + await app.close(); + }); + + test('comments are readable in deterministic pages, scoped to the proposal, and deleted bodies are tombstoned', async () => { + const state = createDatabase(); + const sharedCreatedAt = now(); + state.comments.push( + { + authorId: reader.id, + body: 'First', + createdAt: sharedCreatedAt, + deletedAt: null, + deletedById: null, + id: 'comment-a', + proposalId: 'proposal-1', + }, + { + authorId: author.id, + body: 'Hidden body', + createdAt: sharedCreatedAt, + deletedAt: now(10), + deletedById: author.id, + id: 'comment-b', + proposalId: 'proposal-1', + }, + { + authorId: reader.id, + body: 'Other proposal', + createdAt: now(20), + deletedAt: null, + deletedById: null, + id: 'comment-c', + proposalId: 'proposal-2', + }, + ); + const app = await createApp(state.database); + + const firstPage = await app.inject({ + method: 'GET', + url: '/worlds/world-1/proposals/proposal-1/comments?pageSize=1', + }); + const secondPage = await app.inject({ + method: 'GET', + url: `/worlds/world-1/proposals/proposal-1/comments?pageSize=50&cursor=${firstPage.json().pageInfo.nextCursor}`, + }); + const cappedPage = await app.inject({ + method: 'GET', + url: '/worlds/world-1/proposals/proposal-1/comments?pageSize=500', + }); + + assert.equal(firstPage.statusCode, 200); + assert.deepEqual( + firstPage.json().comments.map((comment: { id: string }) => comment.id), + ['comment-a'], + ); + assert.equal(firstPage.json().pageInfo.hasMore, true); + assert.equal(firstPage.json().totalCount, 2); + assert.equal(secondPage.statusCode, 200); + assert.deepEqual( + secondPage + .json() + .comments.map((comment: { body: string | null; id: string }) => [comment.id, comment.body]), + [['comment-b', null]], + ); + assert.equal(secondPage.json().comments[0].isDeleted, true); + assert.equal(secondPage.json().comments[0].body, null); + assert.equal(secondPage.json().pageInfo.hasMore, false); + assert.equal(secondPage.json().pageInfo.nextCursor, null); + assert.equal(secondPage.json().totalCount, 2); + assert.equal(cappedPage.json().comments.length, 2); + assert.equal(cappedPage.json().totalCount, 2); + assert.equal(cappedPage.json().pageInfo.hasMore, false); + await app.close(); + }); + + test('comment write rate limit is per authenticated user across proposals and does not affect reads or votes', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + + for (let index = 0; index < 5; index += 1) { + const response = await app.inject({ + headers: authHeader(reader), + method: 'POST', + payload: { body: `Allowed comment ${index}` }, + url: + index === 4 + ? '/worlds/world-1/proposals/proposal-same-world/comments' + : '/worlds/world-1/proposals/proposal-1/comments', + }); + + assert.equal(response.statusCode, 201); + } + + const limited = await app.inject({ + headers: authHeader(reader), + method: 'POST', + payload: { body: 'This body must not be stored or echoed.' }, + url: '/worlds/world-1/proposals/proposal-same-world/comments', + }); + const otherUser = await app.inject({ + headers: authHeader(author), + method: 'POST', + payload: { body: 'Independent author allowance.' }, + url: '/worlds/world-1/proposals/proposal-1/comments', + }); + const readAfterLimit = await app.inject({ + method: 'GET', + url: '/worlds/world-1/proposals/proposal-1/comments', + }); + const voteAfterLimit = await app.inject({ + headers: authHeader(reader), + method: 'POST', + payload: { choice: 'REJECT' }, + url: '/worlds/world-1/proposals/proposal-1/votes', + }); + + assert.equal(limited.statusCode, 429, limited.body); + assert.equal(limited.json().code, 'COMMENT_RATE_LIMITED'); + assert.equal(limited.json().error.includes('This body'), false); + assert.ok(limited.headers['retry-after']); + assert.equal(state.comments.length, 6); + assert.equal(otherUser.statusCode, 201); + assert.equal(readAfterLimit.statusCode, 200); + assert.equal(voteAfterLimit.statusCode, 200, voteAfterLimit.body); + assert.equal(state.appVotes.length, 1); + await app.close(); + }); + + test('malformed cursors and world mismatches are rejected safely while closed proposal comments remain readable and writable', async () => { + const state = createDatabase(); + state.comments.push({ + authorId: reader.id, + body: 'Still readable', + createdAt: now(), + deletedAt: null, + deletedById: null, + id: 'comment-closed', + proposalId: 'proposal-closed', + }); + const app = await createApp(state.database); + + const malformedCursor = await app.inject({ + method: 'GET', + url: '/worlds/world-1/proposals/proposal-1/comments?cursor=not-a-cursor', + }); + const mismatchedRead = await app.inject({ + method: 'GET', + url: '/worlds/world-2/proposals/proposal-1/comments', + }); + const closedRead = await app.inject({ + method: 'GET', + url: `/worlds/world-1/proposals/proposal-closed/comments?cursor=${encodeCursor({ + createdAt: new Date('2020-01-01T00:00:00.000Z').toISOString(), + id: 'comment-0', + })}`, + }); + const closedWrite = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { body: 'Comment after the vote window.' }, + url: '/worlds/world-1/proposals/proposal-closed/comments', + }); + + assert.equal(malformedCursor.statusCode, 400); + assert.deepEqual(malformedCursor.json(), { + code: 'INVALID_COMMENT_CURSOR', + error: 'Invalid comment cursor.', + }); + assert.notEqual(malformedCursor.json().error, 'Bad Request'); + assert.equal('statusCode' in malformedCursor.json(), false); + assert.equal('message' in malformedCursor.json(), false); + assert.equal(mismatchedRead.statusCode, 404); + assert.equal(closedRead.statusCode, 200); + assert.equal(closedRead.json().comments[0].body, 'Still readable'); + assert.equal(closedWrite.statusCode, 201); + assert.equal(state.touchedVotes.length, 0); + await app.close(); + }); +}); + +describe('proposal route async error mapping', () => { + test('async canon voting errors use the application error response body', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + + const closedVote = await app.inject({ + headers: authHeader(reader), + method: 'POST', + payload: { choice: 'REJECT' }, + url: '/worlds/world-1/proposals/proposal-closed/votes', + }); + + assert.equal(closedVote.statusCode, 409); + assert.deepEqual(closedVote.json(), { + code: 'PROPOSAL_NOT_VOTING', + error: 'Proposal is not open for voting.', + }); + assert.notEqual(closedVote.json().error, 'Conflict'); + assert.equal('statusCode' in closedVote.json(), false); + assert.equal('message' in closedVote.json(), false); + await app.close(); + }); +}); + +describe('canon transaction confirmation route', () => { + test('rejects unauthenticated confirmation before lookup or mutation', async () => { + const state = createDatabase(); + attachConfirmedDecision(state); + let lookups = 0; + const app = await createApp(state.database, { + hiveBroadcaster: { + async confirmTransactionOperation() { + lookups += 1; + return createConfirmedOperation(); + }, + } as never, + }); + + const response = await app.inject({ + method: 'POST', + payload: { + operationIndex: 0, + transactionId: 'tx-valid', + }, + url: '/worlds/world-1/proposals/proposal-1/canon-transaction/confirm', + }); + + assert.equal(response.statusCode, 401); + assert.equal(lookups, 0); + assert.equal(state.decision.transactionId, null); + assert.equal(state.events.length, 0); + assert.equal(state.auditLogs.length, 0); + await app.close(); + }); + + test('rejects unrelated and insufficient-role users with typed authorization bodies', async () => { + const state = createDatabase(); + attachConfirmedDecision(state); + let lookups = 0; + const app = await createApp(state.database, { + hiveBroadcaster: { + async confirmTransactionOperation() { + lookups += 1; + return createConfirmedOperation(); + }, + } as never, + }); + + const unrelated = await app.inject({ + headers: authHeader(reader), + method: 'POST', + payload: { + operationIndex: 0, + transactionId: 'tx-valid', + }, + url: '/worlds/world-1/proposals/proposal-1/canon-transaction/confirm', + }); + state.memberships.find((membership) => membership.userId === author.id)!.role = + WorldRole.READER; + const insufficientRole = await app.inject({ + headers: authHeader(author), + method: 'POST', + payload: { + operationIndex: 0, + transactionId: 'tx-valid', + }, + url: '/worlds/world-1/proposals/proposal-1/canon-transaction/confirm', + }); + + assert.equal(unrelated.statusCode, 403); + assert.deepEqual(unrelated.json(), { + code: 'INSUFFICIENT_WORLD_PERMISSIONS', + error: 'Insufficient world permissions.', + }); + assert.equal(insufficientRole.statusCode, 403); + assert.deepEqual(insufficientRole.json(), { + code: 'INSUFFICIENT_WORLD_PERMISSIONS', + error: 'Insufficient world permissions.', + }); + assert.equal(lookups, 0); + assert.equal(state.decision.transactionId, null); + assert.equal(state.events.length, 0); + assert.equal(state.auditLogs.length, 0); + await app.close(); + }); + + test('rejects spoofed actor identity from request data', async () => { + const state = createDatabase(); + attachConfirmedDecision(state); + let lookups = 0; + const app = await createApp(state.database, { + hiveBroadcaster: { + async confirmTransactionOperation() { + lookups += 1; + return createConfirmedOperation(); + }, + } as never, + }); + + const response = await app.inject({ + headers: authHeader(reader), + method: 'POST', + payload: { + actorId: author.id, + operationIndex: 0, + transactionId: 'tx-valid', + }, + url: '/worlds/world-1/proposals/proposal-1/canon-transaction/confirm', + }); + + assert.equal(response.statusCode, 400); + assert.equal(response.json().code, 'INVALID_CONFIRMATION_PAYLOAD'); + assert.equal(lookups, 0); + assert.equal(state.decision.transactionId, null); + assert.equal(state.events.length, 0); + assert.equal(state.auditLogs.length, 0); + await app.close(); + }); + + test('authorized proposal author can confirm a matching canon transaction', async () => { + const state = createDatabase(); + attachConfirmedDecision(state); + const app = await createApp(state.database, { + hiveBroadcaster: { + async confirmTransactionOperation() { + return createConfirmedOperation('tx-valid'); + }, + } as never, + }); + + const response = await app.inject({ + headers: authHeader(author), + method: 'POST', + payload: { + operationIndex: 0, + transactionId: 'tx-valid', + }, + url: '/worlds/world-1/proposals/proposal-1/canon-transaction/confirm', + }); + + assert.equal(response.statusCode, 200, response.body); + assert.equal(response.json().decision.transactionId, 'tx-valid'); + assert.equal(response.json().idempotent, false); + assert.equal(state.decision.transactionId, 'tx-valid'); + assert.equal(state.events.length, 1); + assert.equal(state.auditLogs.length, 1); + assert.equal( + (state.auditLogs[0] as { action: WorldAuditAction }).action, + WorldAuditAction.CANON_DECISION_CONFIRMED, + ); + await app.close(); + }); +}); diff --git a/apps/api/src/routes/proposals.ts b/apps/api/src/routes/proposals.ts new file mode 100644 index 0000000..407651a --- /dev/null +++ b/apps/api/src/routes/proposals.ts @@ -0,0 +1,538 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { z } from 'zod'; + +import { env } from '../config/env.js'; +import { authenticateRequest, requireSession } from '../lib/auth-middleware.js'; +import { + acknowledgeProposalAiWarning, + CanonVotingError, + castCanonVote, + confirmCanonTransaction, + createCanonTransactionOperation, + finalizeCanonDecision, + getProposalDetail, + getVoteSummary, +} from '../lib/canon-voting.js'; +import { HafClient } from '../lib/hive/haf-client.js'; +import { prisma } from '../lib/prisma.js'; +import { + createProposalComment, + listProposalComments, + ProposalCommentError, + PROPOSAL_COMMENT_MAX_LENGTH, +} from '../lib/proposal-comments.js'; +import { authorizeWorldPermission } from '../lib/world-authorization.js'; +import type { WorldMembershipLookup } from '../lib/world-authorization.js'; +import { roleHasWorldPermission, WORLD_PERMISSIONS } from '../lib/world-permissions.js'; +import { createHiveReliableBroadcaster } from '../lib/hive/client.js'; +import { + HiveBroadcastError, + type HiveReliableBroadcaster, +} from '../lib/hive/broadcast-reliability.js'; + +const paramsSchema = z.object({ + proposalId: z.string().min(1), + worldId: z.string().min(1), +}); + +const voteBodySchema = z + .object({ + choice: z.enum(['APPROVE', 'REJECT', 'NEEDS_REVISION', 'ALTERNATE_TIMELINE']), + }) + .strict(); + +const commentsQuerySchema = z.object({ + cursor: z.string().min(1).optional(), + pageSize: z.coerce.number().int().positive().optional(), +}); + +const commentBodySchema = z + .object({ + body: z.string().max(PROPOSAL_COMMENT_MAX_LENGTH + 1), + }) + .strict(); + +const confirmBodySchema = z + .object({ + blockNumber: z.coerce.number().int().positive().optional(), + operationIndex: z.coerce.number().int().nonnegative().optional(), + transactionId: z.string().trim().min(1).max(128), + }) + .strict(); + +type RegisterProposalRoutesOptions = { + database?: typeof prisma & WorldMembershipLookup; + hafClient?: HafClient; + hiveBroadcaster?: HiveReliableBroadcaster; +}; + +function authOptions(database: typeof prisma) { + return { + audience: env.AUTH_JWT_AUDIENCE, + database, + issuer: env.AUTH_JWT_ISSUER, + jwtSecret: env.AUTH_JWT_SECRET, + }; +} + +function handleCanonVotingError(error: unknown, reply: FastifyReply) { + if (error instanceof CanonVotingError) { + return reply.code(error.statusCode).send({ + code: error.code, + error: error.message, + }); + } + + if (error instanceof HiveBroadcastError) { + const statusCode = hiveBroadcastStatusCode(error); + + return reply.code(statusCode).send({ + code: error.code, + error: error.message, + }); + } + + throw error; +} + +function hiveBroadcastStatusCode(error: HiveBroadcastError) { + if (error.code === 'BROADCAST_REJECTED' || error.code === 'PERMANENT_TRANSACTION_ERROR') { + return 400; + } + + if (error.code === 'TRANSACTION_EXPIRED') { + return 409; + } + + if (error.code === 'NETWORK_CONFIGURATION_ERROR' || error.code === 'NETWORK_MISMATCH') { + return 503; + } + + return 408; +} + +function handleProposalCommentError(error: unknown, reply: FastifyReply) { + if (error instanceof ProposalCommentError) { + return reply.code(error.statusCode).send({ + code: error.code, + error: error.message, + }); + } + + throw error; +} + +function proposalCommentRateLimitOptions(database: typeof prisma) { + return { + cache: env.PROPOSAL_COMMENT_WRITE_RATE_LIMIT_CACHE, + errorResponseBuilder: () => ({ + code: 'COMMENT_RATE_LIMITED', + error: 'Too many proposal comments. Try again later.', + statusCode: 429, + }), + keyGenerator: async (request: FastifyRequest) => { + const user = await authenticateRequest(request, authOptions(database)); + + return user ? `proposal-comments:user:${user.id}` : `proposal-comments:ip:${request.ip}`; + }, + max: env.PROPOSAL_COMMENT_WRITE_RATE_LIMIT_MAX, + timeWindow: `${env.PROPOSAL_COMMENT_WRITE_RATE_LIMIT_WINDOW_SECONDS} seconds`, + }; +} + +function canonTransactionConfirmRateLimitOptions(database: typeof prisma) { + return { + cache: env.PROPOSAL_COMMENT_WRITE_RATE_LIMIT_CACHE, + errorResponseBuilder: () => ({ + code: 'CANON_TRANSACTION_CONFIRM_RATE_LIMITED', + error: 'Too many canon transaction confirmations. Try again later.', + statusCode: 429, + }), + keyGenerator: async (request: FastifyRequest) => { + const user = await authenticateRequest(request, authOptions(database)); + + return user ? `canon-confirm:user:${user.id}` : `canon-confirm:ip:${request.ip}`; + }, + max: 3, + timeWindow: '60 seconds', + }; +} + +async function authorizeCanonTransactionPermission( + request: FastifyRequest, + reply: FastifyReply, + database: WorldMembershipLookup, + worldId: string, +) { + if (!request.user) { + return false; + } + + const membership = await database.worldMembership.findUnique({ + select: { + id: true, + revokedAt: true, + role: true, + userId: true, + worldId: true, + }, + where: { + revokedAt: null, + worldId_userId: { + userId: request.user.id, + worldId, + }, + }, + }); + + if (!membership || !roleHasWorldPermission(membership.role, WORLD_PERMISSIONS.SUBMIT_PROPOSAL)) { + await reply.code(403).send({ + code: 'INSUFFICIENT_WORLD_PERMISSIONS', + error: 'Insufficient world permissions.', + }); + + return false; + } + + request.worldMembership = membership; + + return true; +} + +export async function registerProposalRoutes( + app: FastifyInstance, + options: RegisterProposalRoutesOptions = {}, +) { + const database = options.database ?? prisma; + const hafClient = options.hafClient ?? new HafClient({ baseUrl: env.HAF_API_URL }); + const hiveBroadcaster = options.hiveBroadcaster ?? createHiveReliableBroadcaster(); + + app.get('/worlds/:worldId/proposals/:proposalId', async (request, reply) => { + const params = paramsSchema.safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + code: 'INVALID_PROPOSAL_ROUTE', + error: 'Invalid proposal route.', + }); + } + + const currentUser = await authenticateRequest(request, authOptions(database)); + + try { + const proposal = await getProposalDetail(database, { + ...(currentUser ? { currentUserId: currentUser.id } : {}), + proposalId: params.data.proposalId, + worldId: params.data.worldId, + }); + + return { + proposal, + }; + } catch (error) { + return handleCanonVotingError(error, reply); + } + }); + + app.get('/worlds/:worldId/proposals/:proposalId/votes/summary', async (request, reply) => { + const params = paramsSchema.safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + code: 'INVALID_PROPOSAL_ROUTE', + error: 'Invalid proposal route.', + }); + } + + try { + return await getVoteSummary(database, params.data); + } catch (error) { + return handleCanonVotingError(error, reply); + } + }); + + app.get('/worlds/:worldId/proposals/:proposalId/comments', async (request, reply) => { + const params = paramsSchema.safeParse(request.params); + const query = commentsQuerySchema.safeParse(request.query); + + if (!params.success || !query.success) { + return reply.code(400).send({ + code: 'INVALID_COMMENT_QUERY', + error: 'Invalid comment query.', + }); + } + + try { + return await listProposalComments(database, { + cursor: query.data.cursor, + pageSize: query.data.pageSize, + proposalId: params.data.proposalId, + worldId: params.data.worldId, + }); + } catch (error) { + return handleProposalCommentError(error, reply); + } + }); + + app.post( + '/worlds/:worldId/proposals/:proposalId/comments', + { + preHandler: [ + app.rateLimit(proposalCommentRateLimitOptions(database)), + requireSession(authOptions(database)), + ], + }, + async (request, reply) => { + const params = paramsSchema.safeParse(request.params); + const body = commentBodySchema.safeParse(request.body); + + if (!params.success || !body.success) { + return reply.code(400).send({ + code: 'INVALID_COMMENT_PAYLOAD', + error: 'Invalid comment payload.', + }); + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.VOTE_ON_PROPOSAL, + database, + ); + + if (!authorized || !request.user) { + return; + } + + try { + const result = await createProposalComment(database, { + authorId: request.user.id, + body: body.data.body, + proposalId: params.data.proposalId, + worldId: params.data.worldId, + }); + + return reply.code(201).send(result); + } catch (error) { + return handleProposalCommentError(error, reply); + } + }, + ); + + app.post( + '/worlds/:worldId/proposals/:proposalId/votes', + { + preHandler: requireSession(authOptions(database)), + }, + async (request, reply) => { + const params = paramsSchema.safeParse(request.params); + const body = voteBodySchema.safeParse(request.body); + + if (!params.success || !body.success) { + return reply.code(400).send({ + code: 'INVALID_VOTE_PAYLOAD', + error: 'Invalid vote payload.', + }); + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.VOTE_ON_PROPOSAL, + database, + ); + + if (!authorized || !request.user) { + return; + } + + try { + return await castCanonVote(database, { + choice: body.data.choice, + proposalId: params.data.proposalId, + voterId: request.user.id, + worldId: params.data.worldId, + }); + } catch (error) { + return handleCanonVotingError(error, reply); + } + }, + ); + + app.post( + '/worlds/:worldId/proposals/:proposalId/ai-warning/acknowledge', + { + preHandler: requireSession(authOptions(database)), + }, + async (request, reply) => { + const params = paramsSchema.safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + code: 'INVALID_PROPOSAL_ROUTE', + error: 'Invalid proposal route.', + }); + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.COMMENT_ON_AI_WARNING, + database, + ); + + if (!authorized || !request.user) { + return; + } + + try { + return await acknowledgeProposalAiWarning(database, { + actorId: request.user.id, + proposalId: params.data.proposalId, + worldId: params.data.worldId, + }); + } catch (error) { + return handleCanonVotingError(error, reply); + } + }, + ); + + app.post( + '/worlds/:worldId/proposals/:proposalId/finalize', + { + preHandler: requireSession(authOptions(database)), + }, + async (request, reply) => { + const params = paramsSchema.safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + code: 'INVALID_PROPOSAL_ROUTE', + error: 'Invalid proposal route.', + }); + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.EXECUTE_CANON_STATUS_AFTER_THRESHOLD, + database, + ); + + if (!authorized || !request.user) { + return; + } + + try { + return await finalizeCanonDecision(database, { + actorId: request.user.id, + proposalId: params.data.proposalId, + worldId: params.data.worldId, + }); + } catch (error) { + return handleCanonVotingError(error, reply); + } + }, + ); + + app.post( + '/worlds/:worldId/proposals/:proposalId/canon-transaction', + { + preHandler: requireSession(authOptions(database)), + }, + async (request, reply) => { + const params = paramsSchema.safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + code: 'INVALID_PROPOSAL_ROUTE', + error: 'Invalid proposal route.', + }); + } + + if (!request.user) { + return reply.code(401).send({ + code: 'AUTHENTICATION_REQUIRED', + error: 'Authentication required.', + }); + } + + const authorized = await authorizeCanonTransactionPermission( + request, + reply, + database, + params.data.worldId, + ); + + if (!authorized) { + return; + } + + try { + return await createCanonTransactionOperation(database, { + proposalId: params.data.proposalId, + signerId: request.user.id, + worldId: params.data.worldId, + }); + } catch (error) { + return handleCanonVotingError(error, reply); + } + }, + ); + + app.post( + '/worlds/:worldId/proposals/:proposalId/canon-transaction/confirm', + { + preHandler: [ + app.rateLimit(canonTransactionConfirmRateLimitOptions(database)), + requireSession(authOptions(database)), + ], + }, + async (request, reply) => { + const params = paramsSchema.safeParse(request.params); + const body = confirmBodySchema.safeParse(request.body); + + if (!params.success || !body.success) { + return reply.code(400).send({ + code: 'INVALID_CONFIRMATION_PAYLOAD', + error: 'Invalid canon transaction confirmation payload.', + }); + } + + if (!request.user) { + return reply.code(401).send({ + code: 'AUTHENTICATION_REQUIRED', + error: 'Authentication required.', + }); + } + + const authorized = await authorizeCanonTransactionPermission( + request, + reply, + database, + params.data.worldId, + ); + + if (!authorized) { + return; + } + + try { + return await confirmCanonTransaction(database, { + ...body.data, + actorId: request.user.id, + hafClient, + hiveBroadcaster, + proposalId: params.data.proposalId, + worldId: params.data.worldId, + }); + } catch (error) { + return handleCanonVotingError(error, reply); + } + }, + ); +} diff --git a/apps/api/src/routes/worlds.test.ts b/apps/api/src/routes/worlds.test.ts index ec4e19a..c2630b4 100644 --- a/apps/api/src/routes/worlds.test.ts +++ b/apps/api/src/routes/worlds.test.ts @@ -12,6 +12,7 @@ import { WorldRole, } from '../generated/prisma/enums.js'; import { signAccessToken } from '../lib/auth-crypto.js'; +import { createWorld, updateWorld } from '../lib/worlds.js'; import { registerWorldRoutes } from './worlds.js'; const founder = { @@ -136,14 +137,101 @@ function createDatabase() { worldId: string; status: ProposalStatus; }> = []; + let failNextAuditCreate = false; + let failNextSeedCreate = false; + let failNextWorldCreateWithSlugConflict = false; + + function cloneState() { + return { + auditLogs: [...auditLogs], + bibleVersions: bibleVersions.map((version) => ({ ...version })), + memberships: memberships.map((membership) => ({ ...membership })), + seeds: seeds.map((seed) => ({ ...seed })), + worlds: worlds.map((world) => ({ ...world })), + }; + } + + function restoreState(snapshot: ReturnType) { + auditLogs.splice(0, auditLogs.length, ...snapshot.auditLogs); + bibleVersions.splice(0, bibleVersions.length, ...snapshot.bibleVersions); + memberships.splice(0, memberships.length, ...snapshot.memberships); + seeds.splice(0, seeds.length, ...snapshot.seeds); + worlds.splice(0, worlds.length, ...snapshot.worlds); + } + + function matchesTextFilter(value: string, filter?: { contains?: string; equals?: string }) { + if (!filter) { + return true; + } + + if (filter.contains) { + return value.toLowerCase().includes(filter.contains.toLowerCase()); + } + + if (filter.equals) { + return value.toLowerCase() === filter.equals.toLowerCase(); + } + + return true; + } + + function matchesWorldWhere( + world: StoredWorld, + where?: { + OR?: Array<{ title?: { contains: string }; description?: { contains: string } }>; + seed?: { genre?: { equals: string }; tone?: { equals: string } }; + }, + ) { + if (!where) { + return true; + } + + if ( + where.OR && + !where.OR.some( + (filter) => + (filter.title ? matchesTextFilter(world.title, filter.title) : false) || + (filter.description ? matchesTextFilter(world.description, filter.description) : false), + ) + ) { + return false; + } + + const seed = seeds.find((candidate) => candidate.worldId === world.id); + + if (where.seed?.genre && !seed) { + return false; + } + + if (seed && !matchesTextFilter(seed.genre, where.seed?.genre)) { + return false; + } + + if (seed && !matchesTextFilter(seed.tone, where.seed?.tone)) { + return false; + } + + return true; + } + + function includeWorld(world: StoredWorld, includeContent = true) { + const versions = bibleVersions + .filter((version) => version.worldId === world.id) + .sort((left, right) => right.versionNumber - left.versionNumber) + .slice(0, 1) + .map((version) => { + if (includeContent) { + return version; + } + + const summary: Partial = { ...version }; + delete summary.content; + return summary; + }); - function includeWorld(world: StoredWorld) { return { ...world, - bibleVersions: bibleVersions - .filter((version) => version.worldId === world.id) - .sort((left, right) => right.versionNumber - left.versionNumber) - .slice(0, 1), + bibleVersions: versions, founder: world.founder, seed: seeds.find((seed) => seed.worldId === world.id) ?? null, }; @@ -151,7 +239,14 @@ function createDatabase() { const database = { async $transaction(this: unknown, callback: (transaction: never) => Promise) { - return callback(this as never); + const snapshot = cloneState(); + + try { + return await callback(this as never); + } catch (error) { + restoreState(snapshot); + throw error; + } }, loreEntry: { async count(args: { where: { worldId: string; status?: LoreStatus } }) { @@ -192,8 +287,8 @@ function createDatabase() { }, }, world: { - async count() { - return worlds.length; + async count(args?: { where?: Parameters[1] }) { + return worlds.filter((world) => matchesWorldWhere(world, args?.where)).length; }, async create(args: { data: { @@ -203,6 +298,25 @@ function createDatabase() { title: string; }; }) { + if (failNextWorldCreateWithSlugConflict) { + failNextWorldCreateWithSlugConflict = false; + throw { + code: 'P2002', + meta: { + target: ['slug'], + }, + }; + } + + if (worlds.some((world) => world.slug === args.data.slug)) { + throw { + code: 'P2002', + meta: { + target: ['slug'], + }, + }; + } + const world = createWorldRecord({ description: args.data.description, founderId: args.data.founderId, @@ -213,8 +327,27 @@ function createDatabase() { worlds.push(world); return world; }, - async findMany(args: { take: number; skip: number }) { - return worlds.slice(args.skip, args.skip + args.take).map(includeWorld); + async findMany(args: { + include?: { bibleVersions?: { select?: Record } }; + take: number; + skip: number; + where?: Parameters[1]; + }) { + const includeContent = Boolean(args.include?.bibleVersions?.select?.content); + + return worlds + .filter((world) => matchesWorldWhere(world, args.where)) + .sort((left, right) => { + const createdAtOrder = right.createdAt.getTime() - left.createdAt.getTime(); + + if (createdAtOrder !== 0) { + return createdAtOrder; + } + + return right.id.localeCompare(left.id); + }) + .slice(args.skip, args.skip + args.take) + .map((world) => includeWorld(world, includeContent)); }, async findUnique(args: { where: { id?: string; slug?: string } }) { const world = worlds.find((candidate) => { @@ -255,6 +388,11 @@ function createDatabase() { }, worldAuditLog: { async create(args: { data: unknown }) { + if (failNextAuditCreate) { + failNextAuditCreate = false; + throw new Error('Injected audit failure.'); + } + auditLogs.push(args.data); return args.data; }, @@ -341,6 +479,11 @@ function createDatabase() { worldId: string; }; }) { + if (failNextSeedCreate) { + failNextSeedCreate = false; + throw new Error('Injected seed failure.'); + } + const seed = { ...args.data, createdAt: new Date('2026-08-05T00:00:00.000Z'), @@ -374,6 +517,15 @@ function createDatabase() { memberships, proposals, seeds, + failNextAuditCreate() { + failNextAuditCreate = true; + }, + failNextSeedCreate() { + failNextSeedCreate = true; + }, + failNextWorldCreateWithSlugConflict() { + failNextWorldCreateWithSlugConflict = true; + }, worlds, }; } @@ -459,10 +611,31 @@ describe('world routes', () => { await app.close(); }); - test('rejects non-json world bible content', async () => { + test('authenticated creation succeeds and ignores client identity and role fields', async () => { const state = createDatabase(); const app = await createApp(state.database); const response = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + ...worldPayload, + founderId: contributor.id, + role: WorldRole.CURATOR, + }, + url: '/worlds', + }); + + assert.equal(response.statusCode, 201); + assert.equal(response.json().world.founderId, founder.id); + assert.equal(state.memberships[0]?.userId, founder.id); + assert.equal(state.memberships[0]?.role, WorldRole.FOUNDER); + await app.close(); + }); + + test('rejects invalid, null, oversized, and deeply nested world bible content', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + const missingContent = await app.inject({ headers: authHeader(), method: 'POST', payload: { @@ -473,8 +646,52 @@ describe('world routes', () => { }, url: '/worlds', }); + const nullContent = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + ...worldPayload, + bible: { + content: null, + }, + }, + url: '/worlds', + }); + const oversized = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + ...worldPayload, + bible: { + content: { + text: 'x'.repeat(101 * 1024), + }, + }, + }, + url: '/worlds', + }); + let deeplyNested: unknown = 'bottom'; + + for (let depth = 0; depth < 42; depth += 1) { + deeplyNested = { child: deeplyNested }; + } + + const nested = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + ...worldPayload, + bible: { + content: deeplyNested, + }, + }, + url: '/worlds', + }); - assert.equal(response.statusCode, 400); + assert.equal(missingContent.statusCode, 400); + assert.equal(nullContent.statusCode, 400); + assert.equal(oversized.statusCode, 400); + assert.equal(nested.statusCode, 400); assert.equal(state.worlds.length, 0); await app.close(); }); @@ -527,7 +744,12 @@ describe('world routes', () => { assert.equal(listResponse.statusCode, 200); assert.equal(listResponse.json().pagination.total, 1); + assert.equal('content' in listResponse.json().worlds[0].currentBibleVersion, false); assert.equal(getResponse.statusCode, 200); + assert.deepEqual( + getResponse.json().world.currentBibleVersion.content, + worldPayload.bible.content, + ); assert.equal(getResponse.json().world.seed.mainConflict, worldPayload.seed.mainConflict); assert.equal(hubResponse.statusCode, 200); assert.equal(hubResponse.json().stats.canonLoreCount, 1); @@ -543,6 +765,46 @@ describe('world routes', () => { await app.close(); }); + test('filters worlds by q, visibility fields, pagination, and consistent counts', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + await app.inject({ + headers: authHeader(), + method: 'POST', + payload: worldPayload, + url: '/worlds', + }); + await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + ...worldPayload, + description: 'Sunlit city state.', + seed: { + ...worldPayload.seed, + genre: 'Science Fantasy', + tone: 'Bright', + }, + title: 'Solar Foundry', + }, + url: '/worlds', + }); + + const search = await app.inject({ method: 'GET', url: '/worlds?q=moon&page=1&pageSize=10' }); + const filtered = await app.inject({ + method: 'GET', + url: '/worlds?genre=fantasy&tone=mystic%20wonder&page=1&pageSize=1', + }); + + assert.deepEqual( + search.json().worlds.map((world: { title: string }) => world.title), + ['Moon Archive'], + ); + assert.deepEqual(filtered.json().pagination, { page: 1, pageSize: 1, total: 1 }); + assert.equal(filtered.json().worlds.length, 1); + await app.close(); + }); + test('allows a founder to update mutable off-chain world seed and bible data', async () => { const state = createDatabase(); const app = await createApp(state.database); @@ -578,9 +840,66 @@ describe('world routes', () => { rules: ['Keep the moon archive coherent.'], }); assert.equal(response.json().world.currentBibleVersion.publishedAt, null); + assert.equal( + state.auditLogs.some( + (entry) => + (entry as { action?: WorldAuditAction }).action === WorldAuditAction.WORLD_BIBLE_UPDATED, + ), + true, + ); await app.close(); }); + test('rejects published and Hive-anchored bible updates without audit records', async () => { + const publishedState = createDatabase(); + const publishedApp = await createApp(publishedState.database); + await publishedApp.inject({ + headers: authHeader(), + method: 'POST', + payload: worldPayload, + url: '/worlds', + }); + publishedState.bibleVersions[0]!.publishedAt = new Date('2026-08-05T00:05:00.000Z'); + + const publishedResponse = await publishedApp.inject({ + headers: authHeader(), + method: 'PATCH', + payload: { bible: { content: { rules: ['No'] } } }, + url: '/worlds/world-1', + }); + + const anchoredState = createDatabase(); + const anchoredApp = await createApp(anchoredState.database); + await anchoredApp.inject({ + headers: authHeader(), + method: 'POST', + payload: worldPayload, + url: '/worlds', + }); + anchoredState.bibleVersions[0]!.hiveReferenceId = 'hive-1'; + + const anchoredResponse = await anchoredApp.inject({ + headers: authHeader(), + method: 'PATCH', + payload: { bible: { content: { rules: ['No'] } } }, + url: '/worlds/world-1', + }); + + assert.equal(publishedResponse.statusCode, 409); + assert.equal(anchoredResponse.statusCode, 409); + assert.deepEqual(publishedState.bibleVersions[0]!.content, worldPayload.bible.content); + assert.deepEqual(anchoredState.bibleVersions[0]!.content, worldPayload.bible.content); + assert.equal( + publishedState.auditLogs.some( + (entry) => + (entry as { action?: WorldAuditAction }).action === WorldAuditAction.WORLD_BIBLE_UPDATED, + ), + false, + ); + await publishedApp.close(); + await anchoredApp.close(); + }); + test('rejects world updates without founder permissions', async () => { const state = createDatabase(); const app = await createApp(state.database); @@ -603,4 +922,115 @@ describe('world routes', () => { assert.equal(response.statusCode, 403); await app.close(); }); + + test('maps slug conflicts to 409 and rolls back failed creation steps', async () => { + const conflictState = createDatabase(); + const app = await createApp(conflictState.database); + const [first, second] = await Promise.all([ + app.inject({ headers: authHeader(), method: 'POST', payload: worldPayload, url: '/worlds' }), + app.inject({ headers: authHeader(), method: 'POST', payload: worldPayload, url: '/worlds' }), + ]); + + assert.equal(first.statusCode, 201); + assert.equal(second.statusCode, 201); + assert.deepEqual( + conflictState.worlds.map((world) => world.slug), + ['moon-archive', 'moon-archive-2'], + ); + + const rollbackState = createDatabase(); + rollbackState.failNextSeedCreate(); + + await assert.rejects( + createWorld(rollbackState.database as never, { + ...worldPayload, + creatorId: founder.id, + }), + ); + + assert.equal(rollbackState.worlds.length, 0); + assert.equal(rollbackState.seeds.length, 0); + assert.equal(rollbackState.bibleVersions.length, 0); + await app.close(); + }); + + test('translates database world slug uniqueness conflicts to 409', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + state.failNextWorldCreateWithSlugConflict(); + + const response = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: worldPayload, + url: '/worlds', + }); + + assert.equal(response.statusCode, 409); + assert.equal(response.json().error, 'World slug is already in use.'); + assert.equal(state.worlds.length, 0); + await app.close(); + }); + + test('rolls back bible updates when audit creation fails', async () => { + const state = createDatabase(); + await createWorld(state.database as never, { ...worldPayload, creatorId: founder.id }); + state.failNextAuditCreate(); + + await assert.rejects( + updateWorld(state.database as never, { + actorId: founder.id, + bible: { + content: { + rules: ['Rollback me.'], + }, + }, + worldId: 'world-1', + }), + ); + + assert.deepEqual(state.bibleVersions[0]!.content, worldPayload.bible.content); + assert.equal( + state.auditLogs.some( + (entry) => + (entry as { action?: WorldAuditAction }).action === WorldAuditAction.WORLD_BIBLE_UPDATED, + ), + false, + ); + }); + + test('returns typed errors for worlds missing seed or bible rows', async () => { + const seedState = createDatabase(); + await createWorld(seedState.database as never, { ...worldPayload, creatorId: founder.id }); + seedState.seeds.length = 0; + + await assert.rejects( + updateWorld(seedState.database as never, { + actorId: founder.id, + seed: { + tone: 'No seed', + }, + worldId: 'world-1', + }), + (error: unknown) => error instanceof Error && error.message === 'World seed is missing.', + ); + + const bibleState = createDatabase(); + await createWorld(bibleState.database as never, { ...worldPayload, creatorId: founder.id }); + bibleState.bibleVersions.length = 0; + + await assert.rejects( + updateWorld(bibleState.database as never, { + actorId: founder.id, + bible: { + content: { + rules: [], + }, + }, + worldId: 'world-1', + }), + (error: unknown) => + error instanceof Error && error.message === 'World bible version is missing.', + ); + }); }); diff --git a/apps/api/src/routes/worlds.ts b/apps/api/src/routes/worlds.ts index 056a394..74d6afc 100644 --- a/apps/api/src/routes/worlds.ts +++ b/apps/api/src/routes/worlds.ts @@ -12,6 +12,7 @@ import { getWorldHub, listWorlds, updateWorld, + WorldError, type WorldDatabase, type WorldSeedInput, } from '../lib/worlds.js'; @@ -24,40 +25,133 @@ const paramsSchema = z.object({ }); const stringListSchema = z.array(z.string().trim().min(1).max(120)).max(20); +const WORLD_BIBLE_MAX_BYTES = 100 * 1024; +const WORLD_BIBLE_MAX_DEPTH = 40; -function isJsonValue(value: unknown): value is Prisma.InputJsonValue { +type JsonValidationResult = + | { + ok: true; + value: Prisma.InputJsonValue; + } + | { + ok: false; + message: string; + }; + +function validateJsonValue(value: unknown): JsonValidationResult { if (value === null) { - return true; + return { + message: 'World bible content cannot be null.', + ok: false, + }; } - if (typeof value === 'string' || typeof value === 'boolean') { - return true; - } + const pending: Array<{ value: unknown; depth: number }> = [{ depth: 0, value }]; - if (typeof value === 'number') { - return Number.isFinite(value); - } + while (pending.length > 0) { + const item = pending.pop()!; - if (Array.isArray(value)) { - return value.every(isJsonValue); - } + if (item.depth > WORLD_BIBLE_MAX_DEPTH) { + return { + message: 'World bible content exceeds the maximum nesting depth.', + ok: false, + }; + } + + if (item.value === null) { + continue; + } - if (typeof value === 'object') { - const prototype = Object.getPrototypeOf(value); + if (typeof item.value === 'string' || typeof item.value === 'boolean') { + continue; + } + + if (typeof item.value === 'number') { + if (!Number.isFinite(item.value)) { + return { + message: 'World bible content must be valid JSON.', + ok: false, + }; + } - if (prototype !== Object.prototype && prototype !== null) { - return false; + continue; } - return Object.values(value).every(isJsonValue); + if (Array.isArray(item.value)) { + for (const child of item.value) { + pending.push({ depth: item.depth + 1, value: child }); + } + + continue; + } + + if (typeof item.value === 'object') { + const prototype = Object.getPrototypeOf(item.value); + + if (prototype !== Object.prototype && prototype !== null) { + return { + message: 'World bible content must be valid JSON.', + ok: false, + }; + } + + for (const child of Object.values(item.value)) { + if (child === undefined) { + return { + message: 'World bible content must be valid JSON.', + ok: false, + }; + } + + pending.push({ depth: item.depth + 1, value: child }); + } + + continue; + } + + return { + message: 'World bible content must be valid JSON.', + ok: false, + }; } - return false; + let serialized: string; + + try { + serialized = JSON.stringify(value); + } catch { + return { + message: 'World bible content must be valid JSON.', + ok: false, + }; + } + + if (!serialized || Buffer.byteLength(serialized, 'utf8') > WORLD_BIBLE_MAX_BYTES) { + return { + message: 'World bible content exceeds the 100 KB limit.', + ok: false, + }; + } + + return { + ok: true, + value: value as Prisma.InputJsonValue, + }; } -const jsonValueSchema = z.custom(isJsonValue, { - message: 'World bible content must be valid JSON.', -}); +const jsonValueSchema = z + .unknown() + .superRefine((value, context) => { + const validation = validateJsonValue(value); + + if (!validation.ok) { + context.addIssue({ + code: 'custom', + message: validation.message, + }); + } + }) + .transform((value) => value as Prisma.InputJsonValue); const worldSeedSchema = z.object({ firstCharacters: stringListSchema.optional(), @@ -114,6 +208,19 @@ function authOptions(database: SessionVerificationDatabase) { }; } +function handleWorldError( + error: unknown, + reply: { code(statusCode: number): { send(body: unknown): unknown } }, +) { + if (error instanceof WorldError) { + return reply.code(error.statusCode).send({ + error: error.message, + }); + } + + throw error; +} + export async function registerWorldRoutes( app: FastifyInstance, options: RegisterWorldRoutesOptions = {}, @@ -142,18 +249,22 @@ export async function registerWorldRoutes( }); } - const world = await createWorld(database, { - ...body.data, - bible: { - ...body.data.bible, - content: body.data.bible.content, - }, - creatorId: authenticatedUser.id, - }); + try { + const world = await createWorld(database, { + ...body.data, + bible: { + ...body.data.bible, + content: body.data.bible.content, + }, + creatorId: authenticatedUser.id, + }); - return reply.code(201).send({ - world, - }); + return reply.code(201).send({ + world, + }); + } catch (error) { + return handleWorldError(error, reply); + } }, ); @@ -238,30 +349,35 @@ export async function registerWorldRoutes( return; } - const world = await updateWorld(database, { - ...(body.data.description ? { description: body.data.description } : {}), - ...(body.data.seed ? { seed: body.data.seed as Partial } : {}), - ...(body.data.title ? { title: body.data.title } : {}), - ...(body.data.bible - ? { - bible: { - ...body.data.bible, - content: body.data.bible.content, - }, - } - : {}), - worldId: params.data.worldId, - }); - - if (!world) { - return reply.code(404).send({ - error: 'World not found.', + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', }); } - return { - world, - }; + try { + const world = await updateWorld(database, { + actorId: request.user.id, + ...(body.data.description ? { description: body.data.description } : {}), + ...(body.data.seed ? { seed: body.data.seed as Partial } : {}), + ...(body.data.title ? { title: body.data.title } : {}), + ...(body.data.bible + ? { + bible: { + ...body.data.bible, + content: body.data.bible.content, + }, + } + : {}), + worldId: params.data.worldId, + }); + + return { + world, + }; + } catch (error) { + return handleWorldError(error, reply); + } }, ); } diff --git a/apps/api/src/scripts/hive-mainnet-smoke.ts b/apps/api/src/scripts/hive-mainnet-smoke.ts new file mode 100644 index 0000000..13b8441 --- /dev/null +++ b/apps/api/src/scripts/hive-mainnet-smoke.ts @@ -0,0 +1,56 @@ +import { createInterface } from 'node:readline/promises'; +import process from 'node:process'; +import { stdin as input, stdout as output } from 'node:process'; +import { config as loadDotenv } from 'dotenv'; + +import { formatHiveMainnetSmokeResult, runHiveMainnetSmoke } from '../lib/hive/mainnet-smoke.js'; +import { buildMainnetSmokeNetworkFromEnv } from '../lib/hive/smoke-operation.js'; + +loadDotenv(); +loadDotenv({ override: false, path: 'apps/api/.env' }); + +const CONFIRMATION_PHRASE = 'BROADCAST HIVELORE SMOKE'; + +async function main() { + const account = process.env.HIVE_SMOKE_ACCOUNT?.trim(); + const postingKey = process.env.HIVE_SMOKE_POSTING_KEY?.trim(); + + if (!account) { + throw new Error('HIVE_SMOKE_ACCOUNT must be set in the local process environment.'); + } + + if (!postingKey) { + throw new Error('HIVE_SMOKE_POSTING_KEY must be set in the local process environment.'); + } + + const network = buildMainnetSmokeNetworkFromEnv(process.env); + const readline = createInterface({ input, output }); + + try { + output.write('WARNING: The next command broadcasts an irreversible Hive mainnet custom_json. '); + const answer = await readline.question(`Type ${CONFIRMATION_PHRASE} to continue: `); + + if (answer.trim() !== CONFIRMATION_PHRASE) { + output.write('verification summary: FAIL\n'); + process.exitCode = 1; + return; + } + } finally { + readline.close(); + } + + const result = await runHiveMainnetSmoke({ + account, + network, + postingKey, + }); + + output.write(`${formatHiveMainnetSmokeResult(result)}\n`); +} + +await main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + + process.stderr.write(`verification summary: FAIL (${message})\n`); + process.exitCode = 1; +}); diff --git a/apps/web/README.md b/apps/web/README.md index 18c15b4..fc1de16 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -32,11 +32,13 @@ Quality commands: ```bash npm run lint --workspace=@hivelore/web +npm test --workspace=@hivelore/web npm run typecheck --workspace=@hivelore/web npm run build --workspace=@hivelore/web ``` -There is currently no configured `test` script. +The repository root `npm test` command runs every workspace test script with +`--workspaces --if-present`, including this web suite. ## Environment Variables diff --git a/apps/web/package.json b/apps/web/package.json index eb3c143..f07a513 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "next dev --port 3000", "lint": "eslint .", + "test": "tsx --test src/**/*.test.ts", "typecheck": "tsc --noEmit", "build": "next build", "clean": "node -e \"fs.rmSync('.next', { recursive: true, force: true }); fs.rmSync('dist', { recursive: true, force: true })\"" diff --git a/apps/web/src/app/worlds/[worldId]/proposals/[proposalId]/canon-vote-screen.tsx b/apps/web/src/app/worlds/[worldId]/proposals/[proposalId]/canon-vote-screen.tsx new file mode 100644 index 0000000..1204daa --- /dev/null +++ b/apps/web/src/app/worlds/[worldId]/proposals/[proposalId]/canon-vote-screen.tsx @@ -0,0 +1,829 @@ +'use client'; + +import { + type InfiniteData, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; +import { useEffect, useMemo, useState } from 'react'; + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { LoadingState } from '@/components/states/loading-state'; +import { Textarea } from '@/components/ui/textarea'; +import { + acknowledgeProposalAiWarning, + castProposalVote, + confirmCanonTransaction, + createProposalComment, + createCanonTransaction, + finalizeProposal, + getProposal, + getProposalComments, + PROPOSAL_COMMENT_MAX_LENGTH, + type ProposalCommentsResponse, + type ProposalDetail, + type VoteChoice, +} from '@/lib/api/proposals'; +import { refreshAuthSession, type SafeUser } from '@/lib/api/auth'; +import { ApiError } from '@/lib/api/errors'; +import { requestCanonDecisionSignature } from '@/lib/hive/canon-signing'; + +const voteLabels: Record = { + APPROVE: 'Approve', + ALTERNATE_TIMELINE: 'Alternate Timeline', + NEEDS_REVISION: 'Needs Revision', + REJECT: 'Reject', +}; + +const voteHelp: Record = { + APPROVE: 'Counts for canon approval.', + ALTERNATE_TIMELINE: 'Counts for participation, not approval percentage.', + NEEDS_REVISION: 'Counts for participation, not approval percentage.', + REJECT: 'Counts against canon approval.', +}; + +function getErrorMessage(error: unknown) { + if (error instanceof ApiError) { + return error.body?.error ?? 'Request failed.'; + } + + if (error instanceof Error) { + return error.message; + } + + return 'Request failed.'; +} + +function formatDate(value: string | null) { + if (!value) { + return 'Not scheduled'; + } + + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(value)); +} + +function formatCountdown(value: string | null) { + if (!value) { + return 'Voting window missing'; + } + + const remaining = new Date(value).getTime() - Date.now(); + + if (remaining <= 0) { + return 'Voting ended'; + } + + const hours = Math.floor(remaining / 3_600_000); + const minutes = Math.floor((remaining % 3_600_000) / 60_000); + + return `${hours}h ${minutes}m remaining`; +} + +function statusVariant(status: string) { + if (status === 'APPROVED_FOR_PUBLICATION') { + return 'ready-to-publish' as const; + } + + if (status === 'REJECTED') { + return 'rejected' as const; + } + + if (status === 'ALTERNATE_TIMELINE') { + return 'alternate-timeline' as const; + } + + if (status === 'PUBLISHED') { + return 'published-on-hive' as const; + } + + return 'proposal' as const; +} + +function contentText(value: unknown) { + if (!value || typeof value !== 'object') { + return String(value ?? ''); + } + + const text: string[] = []; + const pending = [value]; + + while (pending.length > 0) { + const item = pending.pop(); + + if (!item || typeof item !== 'object') { + continue; + } + + const record = item as Record; + + if (typeof record.text === 'string') { + text.push(record.text); + } + + if (Array.isArray(record.content)) { + pending.push(...record.content); + } + } + + return text.join(' ').trim() || JSON.stringify(value, null, 2); +} + +function ResultPanel({ proposal }: { proposal: ProposalDetail }) { + const decision = proposal.decision; + + if (!decision && proposal.status === 'VOTING' && proposal.votingEndsAt) { + const ended = Date.now() >= new Date(proposal.votingEndsAt).getTime(); + + return ( + + {ended ? 'Awaiting finalization' : 'Voting open'} + + {ended + ? 'The 48-hour voting window has ended. A curator can finalize the frozen result.' + : 'Votes can be changed until the deadline. No result is final before then.'} + + + ); + } + + if (!decision) { + return ( + + No decision yet + + This proposal has not entered a terminal governance state. + + + ); + } + + const confirmed = Boolean(decision.hiveEventId); + const approved = decision.outcome === 'APPROVED_FOR_PUBLICATION'; + + return ( + + + {confirmed + ? 'Canon decision confirmed on Hive' + : approved + ? 'Awaiting Hive signature' + : decision.outcome.replaceAll('_', ' ')} + + + {confirmed + ? `Verified transaction ${decision.transactionId}.` + : approved + ? 'This is approved for publication, not published canon. The author still signs a Hive custom_json decision.' + : 'This proposal did not become approved-for-publication under the MVP voting policy.'} + + + ); +} + +function ProgressBar({ label, value }: { label: string; value: number }) { + const safeValue = Math.max(0, Math.min(100, value)); + + return ( +
+
+ {label} + {safeValue.toFixed(0)}% +
+
+
+
+
+ ); +} + +function formatCommentTime(value: string) { + const date = new Date(value); + const diffMs = Date.now() - date.getTime(); + const minute = 60_000; + const hour = 60 * minute; + const day = 24 * hour; + + if (diffMs >= 0 && diffMs < minute) { + return 'Just now'; + } + + if (diffMs >= 0 && diffMs < hour) { + return `${Math.floor(diffMs / minute)}m ago`; + } + + if (diffMs >= 0 && diffMs < day) { + return `${Math.floor(diffMs / hour)}h ago`; + } + + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date); +} + +export function CanonVoteScreen({ proposalId, worldId }: { proposalId: string; worldId: string }) { + const queryClient = useQueryClient(); + const [accessToken, setAccessToken] = useState(null); + const [currentUser, setCurrentUser] = useState(null); + const [sessionChecked, setSessionChecked] = useState(false); + const [receipt, setReceipt] = useState({ + blockNumber: '', + operationIndex: '', + transactionId: '', + }); + const [message, setMessage] = useState(null); + const [commentBody, setCommentBody] = useState(''); + + useEffect(() => { + let mounted = true; + + refreshAuthSession() + .then((session) => { + if (!mounted) { + return; + } + + setAccessToken(session.accessToken); + setCurrentUser(session.user); + }) + .catch(() => { + if (mounted) { + setAccessToken(null); + setCurrentUser(null); + } + }) + .finally(() => { + if (mounted) { + setSessionChecked(true); + } + }); + + return () => { + mounted = false; + }; + }, []); + + const proposalQuery = useQuery({ + queryFn: () => getProposal(worldId, proposalId, accessToken ?? undefined), + queryKey: ['proposal', worldId, proposalId, accessToken], + }); + + const commentsQuery = useInfiniteQuery< + ProposalCommentsResponse, + Error, + InfiniteData, + [string, string, string], + string | null + >({ + getNextPageParam: (lastPage) => lastPage.pageInfo.nextCursor ?? undefined, + initialPageParam: null as string | null, + queryFn: ({ pageParam }) => + getProposalComments({ + cursor: pageParam, + pageSize: 20, + proposalId, + worldId, + }), + queryKey: ['proposal-comments', worldId, proposalId], + }); + + const proposal = proposalQuery.data?.proposal; + const invalidateProposal = () => + queryClient.invalidateQueries({ queryKey: ['proposal', worldId, proposalId] }); + const invalidateComments = () => + queryClient.invalidateQueries({ queryKey: ['proposal-comments', worldId, proposalId] }); + + const voteMutation = useMutation({ + mutationFn: (choice: VoteChoice) => + castProposalVote({ accessToken: accessToken ?? '', choice, proposalId, worldId }), + onSuccess: () => { + setMessage('Vote saved.'); + void invalidateProposal(); + }, + }); + + const finalizeMutation = useMutation({ + mutationFn: () => finalizeProposal({ accessToken: accessToken ?? '', proposalId, worldId }), + onSuccess: () => { + setMessage('Decision finalized from the frozen tally.'); + void invalidateProposal(); + }, + }); + + const acknowledgeAiWarningMutation = useMutation({ + mutationFn: () => + acknowledgeProposalAiWarning({ + accessToken: accessToken ?? '', + proposalId, + worldId, + }), + onSuccess: () => { + setMessage('AI warning acknowledged.'); + void invalidateProposal(); + }, + }); + + const signMutation = useMutation({ + mutationFn: async () => { + const operation = await createCanonTransaction({ + accessToken: accessToken ?? '', + proposalId, + worldId, + }); + return requestCanonDecisionSignature(operation); + }, + onSuccess: (nextReceipt) => { + setMessage( + nextReceipt.provider === 'manual' + ? 'custom_json copied. Broadcast it with posting authority, then enter the confirmed operation details.' + : 'Signature requested. Enter the confirmed Hive operation details after broadcast.', + ); + + if (nextReceipt.transactionId) { + setReceipt((current) => ({ ...current, transactionId: nextReceipt.transactionId ?? '' })); + } + + void invalidateProposal(); + }, + }); + + const confirmMutation = useMutation({ + mutationFn: () => + confirmCanonTransaction({ + accessToken: accessToken ?? '', + blockNumber: Number(receipt.blockNumber), + operationIndex: Number(receipt.operationIndex), + proposalId, + transactionId: receipt.transactionId, + worldId, + }), + onSuccess: () => { + setMessage('Hive operation verified and recorded.'); + void invalidateProposal(); + }, + }); + + const trimmedCommentBody = commentBody.trim(); + const commentTooLong = commentBody.length > PROPOSAL_COMMENT_MAX_LENGTH; + const commentValidationMessage = !trimmedCommentBody + ? 'Write a comment before posting.' + : commentTooLong + ? `Comments can be ${PROPOSAL_COMMENT_MAX_LENGTH} characters or fewer.` + : null; + + const commentMutation = useMutation({ + mutationFn: () => + createProposalComment({ + accessToken: accessToken ?? '', + body: commentBody, + proposalId, + worldId, + }), + onSuccess: () => { + setCommentBody(''); + setMessage('Comment posted.'); + void invalidateComments(); + }, + }); + + const votingClosed = useMemo(() => { + if (!proposal?.votingEndsAt) { + return true; + } + + return Date.now() >= new Date(proposal.votingEndsAt).getTime(); + }, [proposal?.votingEndsAt]); + + if (proposalQuery.isLoading || !sessionChecked) { + return ; + } + + if (proposalQuery.isError || !proposal) { + return ( + + Proposal unavailable + {getErrorMessage(proposalQuery.error)} + + ); + } + + const approvalProgress = + proposal.tally.approvalDenominator > 0 ? proposal.tally.approvalPercentageBps / 100 : 0; + const participationProgress = (proposal.tally.totalVotes / 5) * 100; + const isAuthor = currentUser?.id === proposal.author.id; + const canVote = + Boolean(accessToken) && proposal.status === 'VOTING' && !votingClosed && !isAuthor; + const canAcknowledgeAiWarning = + Boolean(accessToken) && + proposal.aiWarning.acknowledgmentRequired && + !proposal.aiWarning.acknowledged; + const canFinalize = + Boolean(accessToken) && + proposal.status === 'VOTING' && + votingClosed && + (!proposal.aiWarning.acknowledgmentRequired || proposal.aiWarning.acknowledged); + const canSign = + Boolean(accessToken) && + proposal.decision?.outcome === 'APPROVED_FOR_PUBLICATION' && + !proposal.decision.hiveEventId && + currentUser?.id === proposal.author.id; + const canConfirm = + Boolean(accessToken) && + Boolean(receipt.transactionId.trim()) && + Number.isInteger(Number(receipt.blockNumber)) && + Number.isInteger(Number(receipt.operationIndex)); + + return ( +
+
+
+ + {proposal.status.replaceAll('_', ' ')} + + {proposal.aiWarning.acknowledgmentRequired ? ( + Major AI warning + ) : null} + {proposal.branchLabel ? ( + {proposal.branchLabel} + ) : null} +
+
+

+ {proposal.world.title} / {proposal.proposalType.replaceAll('_', ' ')} +

+

+ {proposal.title} +

+

+ {proposal.summary || 'No summary provided.'} +

+

Author: @{proposal.author.hiveUsername}

+
+
+ +
+ {message} +
+ + {message ? ( + + Status + {message} + + ) : null} + + {voteMutation.error || + finalizeMutation.error || + signMutation.error || + confirmMutation.error || + commentMutation.error ? ( + + Action failed + + {getErrorMessage( + voteMutation.error ?? + finalizeMutation.error ?? + signMutation.error ?? + confirmMutation.error ?? + commentMutation.error, + )} + + + ) : null} + +
+
+
+

Proposal Content

+

+ {contentText(proposal.proposedContent)} +

+
+ +
+
+

AI Report

+ {proposal.aiWarning.acknowledgmentRequired ? ( + + {proposal.aiWarning.acknowledged + ? 'Warning acknowledged' + : 'Acknowledgment required'} + + ) : null} +
+

+ {proposal.aiWarning.summary ?? + 'No major AI warning is attached to this proposal decision.'} +

+
+ +
+

Branch And Conflict State

+
+
+
Base canon version
+
{proposal.baseCanonVersionId ?? 'Current world base at submission'}
+
+
+
Branch base
+
+ {proposal.branchBaseLoreEntryId ?? + proposal.branchParentProposalId ?? + 'Main continuity'} +
+
+
+ {proposal.conflictMetadata ? ( +
+                {JSON.stringify(proposal.conflictMetadata, null, 2)}
+              
+ ) : null} +
+ +
+
+
+

Discussion

+

+ {commentsQuery.data?.pages[0]?.totalCount ?? 0} comments +

+
+ {commentsQuery.isError ? ( + + ) : null} +
+ + {commentsQuery.isLoading ? ( +
+
+
+
+ ) : commentsQuery.isError ? ( + + Comments unavailable + {getErrorMessage(commentsQuery.error)} + + ) : null} + + {commentsQuery.data && + commentsQuery.data.pages.every((page) => page.comments.length === 0) ? ( +
+ No discussion yet. +
+ ) : null} + + {commentsQuery.data ? ( +
+ {commentsQuery.data.pages.flatMap((page) => + page.comments.map((comment) => ( +
+
+ @{comment.author.hiveUsername} + {comment.author.displayName ? ( + + {comment.author.displayName} + + ) : null} + +
+ {comment.isDeleted ? ( +

+ This comment was removed by moderation. +

+ ) : ( +

+ {comment.body} +

+ )} +
+ )), + )} +
+ ) : null} + + {commentsQuery.hasNextPage ? ( + + ) : null} + +
{ + event.preventDefault(); + + if (!accessToken || commentValidationMessage || commentMutation.isPending) { + return; + } + + commentMutation.mutate(); + }} + > + +