Skip to content

feat(server): AuditLog, DB indexes, security hardening, observability#237

Merged
jinglescode merged 1 commit into
mainfrom
feat/server-hardening-and-audit-log
May 10, 2026
Merged

feat(server): AuditLog, DB indexes, security hardening, observability#237
jinglescode merged 1 commit into
mainfrom
feat/server-hardening-and-audit-log

Conversation

@jinglescode
Copy link
Copy Markdown
Member

Summary

Comprehensive server-side hardening pass that:

  • Lands the AuditLog table + 17 supporting indexes (already deployed to prod via prisma migrate deploy)
  • Closes auth/security gaps discovered in audit (notably the ownerAddress === \"all\" wildcard bypass)
  • Adds observability primitives (audit, logger) and wires emitters into auth + wallet/signable/transaction mutations
  • Centralizes ctx typing (new AuthCtx) and shared auth helpers (src/server/api/auth.ts)
  • Adds SSRF defense to /api/v1/og
  • Fixes schema drift so prisma migrate diff is non-destructive on prod

Depends on #236 — the build-crash fix. Without it, next build --webpack fails on /wallets/[wallet]/transactions/new.

Production migration status

The migration 20260510160404_audit_log_and_indexes has already been applied to the multisig Supabase production DB. prisma migrate deploy on this branch is idempotent — Prisma sees the migration in _prisma_migrations and skips it.

The migration is purely additive: 1 AlterTable (adds defaults to two array columns), 1 CreateTable (AuditLog), 22 CreateIndex. Zero destructive operations.

Schema drift archaeology

Three drift items were resolved by reflecting prod in schema.prisma (not by altering prod):

Drift Origin Resolution
Crowdfund table branch aiken-crowdfund (never merged); applied to prod via db push. Placeholder migrations on main show "applied but file not found locally." Restored model declaration with // Retained — feature unused in app code. Do not drop without migration plan.
WalletBotAccess PK vs UNIQUE #207 (commit 1facdc3) — schema authored with @@unique; migration SQL used PRIMARY KEY. They disagreed at landing. Changed @@unique@@id.
Ballot.updatedAt default accumulated drift across 109bade/97592cc/de1e762 — annotation simplified to bare @updatedAt without regenerating migration; default still in prod DDL. Restored @default(now()) @updatedAt.

Recommend retiring the unmerged aiken-crowdfund branch on GitHub after this lands so nobody picks it up later — its 3b10fd0 commit contains a destructive DROP TABLE Crowdfund.

What's in this PR

Database

  • AuditLog model (5 fields + 5 indexes)
  • 17 app-table indexes (12 btree + 2 GIN + 3 schema-only matching prod)
  • Restored prod-matching declarations for Crowdfund, WalletBotAccess, Ballot.updatedAt

Observability

  • src/lib/observability/audit.tsaudit(db, event) emitter; never throws (audit miss must not break user flow); redacts secrets
  • src/lib/observability/logger.ts — structured logger; JSON in prod, human-readable in dev

Security fixes

  • Closed ownerAddress === \"all\" bypass in assertWalletAccess
  • lookupMultisigWallet validates stake-credential-hash format before query
  • Centralized rate-limit + request-guard surface
  • Stricter JWT type narrowing (isBotJwt); tighter walletSession expiry
  • SSRF defense on /api/v1/og: https-only, allow-listed hosts, private-IP DNS rejection, no redirect-follow

Audit emitters

Wired into auth flow, wallet mutations, signable/transaction mutations, bot privilege grants. All non-blocking.

Auth helpers

  • New src/server/api/auth.ts consolidates requireSessionAddress, getSessionAddresses, wallet-access checks
  • All routers and v1 API handlers migrated

ctx typing

  • New AuthCtx and TRPCContext exported from src/server/api/trpc.ts
  • Removed all (ctx as any) casts in router helpers
  • protectedProcedure middleware: correctly narrows sessionWallets / primaryWallet / sessionAddress

Test infrastructure

  • jest.config.mjs — CSS moduleNameMapper, ESM transformIgnorePatterns
  • setupEnv.cjs — pre-test env bootstrap so src/env.js doesn't throw
  • Frozen wall clock (Date.now/new Date) for byte-identical runs
  • New tests: og.test.ts (9 SSRF cases), signing.test.ts (regression tripwires)
  • Existing tests updated for Jest 30 strict mock typing

Removed dependencies

  • react-hook-form, @hookform/resolvers — last consumer was src/components/ui/form.tsx, deleted (no remaining imports anywhere in src/)
  • yaml — no remaining imports

Test plan

  • Typecheck passes (verified locally: clean)
  • Tests pass deterministically across two runs (verified locally: 165/165 staged)
  • Build succeeds — requires fix(build): lazy-init mainnet provider; drop global sideEffects:false #236 to be merged first
  • After deploy, verify a signed action emits one AuditLog row
  • After deploy, verify /api/v1/og rejects http://, private IPs, redirects

Stats

45 files changed, +1650 / -554

🤖 Generated with Claude Code

…bility

Comprehensive server-side hardening pass: closes auth/security gaps,
adds an append-only AuditLog for security-relevant events, indexes
frequently queried columns, centralizes ctx typing, and lands shared
auth helpers.

## Database (already deployed to prod via prisma migrate deploy)

- New `AuditLog` table for append-only security audit trail
  (auth flows, wallet/transaction mutations, privilege grants, signer
  changes). Five indexes for the common access patterns.
- Btree indexes on Wallet/NewWallet `ownerAddress`, Signable+Transaction
  `walletId`/`state`/`(walletId, state)`, Proxy `walletId`/`userId`/
  `(walletId, isActive)`/`(userId, isActive)`, Ballot `walletId`,
  BalanceSnapshot `walletId`/`(walletId, snapshotDate)`.
- GIN indexes on Wallet/NewWallet `signersAddresses` (array_ops) — the
  signer-membership query was a full table scan.
- Restored `Crowdfund` model declaration (production drift: table exists
  in prod but was never declared in main's schema; see PR description
  for full archaeology). Marked as retained-but-unused.
- WalletBotAccess: `@@unique` -> `@@id` to match prod (drift from
  PR #207 / commit 1facdc3 where schema and migration disagreed at
  landing).
- Ballot.updatedAt: restored `@default(now()) @updatedAt` to match
  prod's column default (drift accumulated across multiple commits).
- Ballot.anchorUrls / anchorHashes: added `DEFAULT ARRAY[]::TEXT[]` to
  match the schema's `@default([])` annotation.

## Observability primitives

- `src/lib/observability/audit.ts` — `audit(db, event)` emitter; never
  throws (audit miss must not break user flow); redacts secrets in
  metadata before write.
- `src/lib/observability/logger.ts` — structured logger; JSON in prod,
  human-readable in dev; never logs raw tokens/signatures/cookies.

## Security fixes (Wave 1-3)

- Closed `ownerAddress === "all"` bypass in `assertWalletAccess`. The
  string "all" was being treated as a wildcard owner — any session
  could claim ownership of any wallet whose `ownerAddress` happened to
  contain that literal.
- `lookupMultisigWallet`: validate stake-credential-hash format before
  query (prevents prefix-match abuse and full-table scans on malformed
  input).
- Centralized rate-limit and request-guard surface (`src/lib/security/
  rateLimit.ts`, `requestGuards`). Bot routes now use bot-scoped
  rate limit; user routes use IP-scoped.
- `verifyJwt`: stricter token-type narrowing; explicit `isBotJwt`
  predicate.
- `walletSession`: tighter expiry handling, no implicit refresh.

## Auth helpers (Wave 8)

- New `src/server/api/auth.ts` consolidates `requireSessionAddress`,
  `getSessionAddresses`, and wallet-access checks that were duplicated
  in nearly every router. One source of truth, one place to extend.
- All routers and v1 API handlers migrated.

## ctx typing (Wave 2)

- New `AuthCtx` and `TRPCContext` exported from `src/server/api/trpc.ts`.
- All router helpers use `AuthCtx` instead of `any`.
- `protectedProcedure` middleware: type-narrows `sessionWallets`,
  `primaryWallet`, `sessionAddress` correctly.

## Audit emitters (Wave 5)

Wired into:
- auth flow (login success/failure, JWT mint, bot auth)
- wallet mutations (create, update, archive, transfer, signer changes)
- signable + transaction mutations (sign, reject, broadcast)
- bot privilege grants

All emitters fire after the underlying action and never block it.

## SSRF defense for `/api/v1/og`

The OG metadata endpoint now:
- requires https, denies non-allowlisted hosts
- DNS-resolves and rejects private/loopback/link-local addresses
- denies upstream redirects (no auto-follow)

`OG_ALLOWED_HOSTS` env var configures the allow list; "*" allows any
public host (still SSRF-guarded).

## Test infrastructure

- jest.config.mjs — moduleNameMapper for CSS, transformIgnorePatterns
  for ESM-only deps (superjson, @trpc, @meshsdk, jose, etc.)
- setupEnv.cjs — pre-test env bootstrap (SKIP_ENV_VALIDATION=1, dummy
  DB/JWT/Blockfrost values) so `src/env.js` doesn't throw on import.
- Frozen wall clock (`Date.now`/`new Date`) for byte-identical test
  runs; real timer APIs preserved.
- `__mocks__/styleMock.cjs` — CSS imports mock for jest.

## Tests

- New: `og.test.ts` (SSRF tripwire suite — 9 cases for the og handler).
- New: `signing.test.ts` (source tripwires preventing the
  `return true ? signature : undefined` regression and similar).
- Updated existing tests to match Jest 30 strict mock typing
  (jest.fn<...>() generics) and new ctx fields.

## Verification

- Typecheck clean
- All 165 staged-suite tests pass deterministically across two runs
- Migration `20260510160404_audit_log_and_indexes` already applied to
  the multisig Supabase production DB — `prisma migrate deploy` on
  this branch is a no-op (idempotent).

Depends on: #236 (build fix; without it `next build --webpack` will
crash on `/wallets/[wallet]/transactions/new`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 10, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
multisig Ready Ready Preview, Comment May 10, 2026 8:32am

Request Review

Comment thread src/pages/api/v1/og.ts
Comment on lines +95 to +100
const response = await fetch(target, {
method: "GET",
redirect: "manual",
signal: controller.signal,
headers: { "user-agent": "MeshMultisigOGFetcher/1.0" },
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants