diff --git a/.env.example b/.env.example index 109405c..cb1c373 100644 --- a/.env.example +++ b/.env.example @@ -1,37 +1,8 @@ -# GhimTech Tax — environment template -# Copy to .env for local development. NEVER commit real secrets. -# See docs/environment.md for the full reference. - -# ---- Core ---- -GHIMTECH_ENV=development # development | test | staging | production -PORT=4000 -HOST=0.0.0.0 -NEXT_PUBLIC_API_URL=http://localhost:4000 -GHIMTECH_CORS_ORIGIN=http://localhost:3000 - -# ---- Encryption keys (REQUIRED outside development) ---- -# Generate each with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" -# In staging/production these come from the secret manager, never from files. -GHIMTECH_MASTER_KEY= -GHIMTECH_INDEX_KEY= - -# ---- Database ---- -# Local: docker compose up postgres -DATABASE_URL=postgresql://ghimtech:ghimtech_dev@localhost:5432/ghimtech_tax -# Set GHIMTECH_STORE=memory to run the API without Postgres (development only). -#GHIMTECH_STORE=memory - -# ---- Redis / queues ---- -REDIS_URL=redis://localhost:6379 -GHIMTECH_API_URL=http://localhost:4000 -# Service-account token for the worker (create a dedicated PREPARER-role service user). -GHIMTECH_WORKER_TOKEN= - -# ---- E-file provider ---- -# mock | sandbox | column-tax | april | generic -# Real transmitter adapters refuse to run until implemented against official -# provider contracts — see docs/efile-providers.md. -GHIMTECH_EFILE_PROVIDER=mock - -# ---- Sessions ---- -GHIMTECH_SESSION_TTL_MINUTES=30 +NEXT_PUBLIC_SITE_URL=https://ghimtech.org +# Server-only HTTPS endpoint that durably receives project enquiries. +PROJECT_WEBHOOK_URL= +PROJECT_WEBHOOK_TOKEN= +# Shared rate limiter. Required for submissions. +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= +RATE_LIMIT_SECRET= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad0b9b4..9c8d27f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,79 +1,23 @@ name: CI - on: push: branches: [main] pull_request: - -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - +permissions: + contents: read jobs: verify: - name: Lint, typecheck, test, build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - - - name: Install - run: pnpm install --frozen-lockfile - - - name: Format check - run: pnpm format:check - - - name: Lint - run: pnpm lint - - - name: Typecheck - run: pnpm typecheck - - - name: Unit and integration tests - run: pnpm test - - - name: Build all apps and packages - run: pnpm build - - migrations: - name: Database migration validation - runs-on: ubuntu-latest - services: - postgres: - image: postgres:17-alpine - env: - POSTGRES_USER: ghimtech - POSTGRES_PASSWORD: ghimtech_ci - POSTGRES_DB: ghimtech_tax_ci - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U ghimtech" - --health-interval 5s - --health-timeout 3s - --health-retries 10 - env: - DATABASE_URL: postgresql://ghimtech:ghimtech_ci@localhost:5432/ghimtech_tax_ci - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - name: Install - run: pnpm install --frozen-lockfile - - name: Apply migrations to a clean database - working-directory: packages/database - run: pnpm prisma migrate deploy - - name: Verify schema drift - working-directory: packages/database - # The database was just migrated from scratch; comparing it to the - # schema proves migrations fully reproduce the datamodel. - run: pnpm prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel ./prisma/schema.prisma --exit-code + - run: pnpm install --frozen-lockfile + - run: pnpm format:check + - run: pnpm lint + - run: pnpm test + - run: pnpm build + - run: pnpm typecheck diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 2a98ee7..8e8252a 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -1,15 +1,14 @@ name: Security - on: push: branches: [main] pull_request: schedule: - - cron: "0 6 * * 1" # weekly sweep - + - cron: "0 6 * * 1" +permissions: + contents: read jobs: dependency-audit: - name: Dependency scanning runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -18,33 +17,14 @@ jobs: with: node-version: 22 cache: pnpm - - name: Install - run: pnpm install --frozen-lockfile - - name: Audit production dependencies - run: pnpm audit --prod --audit-level=high - + - run: pnpm install --frozen-lockfile + - run: pnpm audit --prod --audit-level=high secret-scan: - name: Secret scanning runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Gitleaks - uses: gitleaks/gitleaks-action@v2 + - uses: gitleaks/gitleaks-action@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - container-scan: - name: Container scanning - runs-on: ubuntu-latest - if: github.event_name != 'schedule' - steps: - - uses: actions/checkout@v4 - - name: Build API image - run: docker build -f infrastructure/api.Dockerfile -t ghimtech-tax-api:ci . - - name: Install Trivy - run: | - curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin - - name: Trivy scan - run: trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 ghimtech-tax-api:ci diff --git a/.gitleaks.toml b/.gitleaks.toml index 4957ab5..5474857 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -1,21 +1,2 @@ -# Gitleaks configuration for GhimTech Tax. -# -# Allowlist covers SYNTHETIC values only: -# - test files use fixed fixture passwords and the public RFC 6238 TOTP -# test vector ("12345678901234567890" in base32) -# - the seed script's development password is documented and reset-forced -# Real secrets are never allowlisted. Anything outside these paths that -# trips a rule must fail the scan. - [extend] useDefault = true - -[allowlist] -description = "Synthetic test fixtures and documented dev-seed credentials" -paths = [ - '''.*\.test\.ts$''', - '''apps/api/src/test-helpers\.ts$''', - '''packages/database/prisma/seed\.ts$''', - '''docs/setup\.md$''', - '''README\.md$''', -] diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index d104164..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,18 +0,0 @@ -# Changelog - -## 0.1.0 — 2026-08-02 - -Initial platform build. - -- Monorepo foundation (pnpm, Turborepo, strict TypeScript, CI + security workflows) -- Normalized tax domain with 23-state filing lifecycle and RBAC permission map -- Deterministic federal (Form 1040) and Pennsylvania (PA-40) engines for tax year 2025 with versioned rules, calculation traces, and blocking diagnostics -- Field-level envelope encryption, TOTP MFA, hash-chained audit logging -- Provider-neutral e-file architecture with mock/sandbox providers and contract tests -- Forms engine with snapshot hashing and watermarked printable documents -- Document pipeline: hardened uploads, malware-scan boundary, OCR with mandatory human verification -- Complete Prisma schema with initial migration and synthetic seed -- Fastify API (auth, clients, returns, signatures, e-file, documents, audit, dashboard) with end-to-end workflow tests -- BullMQ worker for acknowledgment polling -- Next.js web application with the GhimTech Tax design system -- Full documentation set: architecture, security and threat models, tax-rule sources, runbooks, ADRs diff --git a/README.md b/README.md index e5d9310..666b656 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,62 @@ -# GhimTech Tax +# GhimTech -**GhimTech Tax — Built by GhimTech.** A private professional tax-preparation and e-file platform for federal (Form 1040) and Pennsylvania (PA-40) individual returns, developed and operated by GhimTech. +Software built around how your business actually works. -The initial deployment serves GhimTech's own preparation practice — the owner, family, friends, and a small set of approved clients — on a codebase engineered to production standards so future commercial expansion needs no rewrite. +GhimTech is Durga Ghimeray’s software studio, focused on custom business software, CRM systems, workflow automation, and operational tools. CyanjelHomeCare LLC is the flagship customer. -## What it does +## Application -- **Client management and guided intake** with encrypted identity capture (SSNs and bank numbers are envelope-encrypted; only last-4 is ever displayed) -- **Document vault**: hardened uploads (magic-byte validation, malware scanning), OCR suggestions that always require human verification -- **Deterministic tax engines** for tax year 2025 — federal Form 1040 (standard/itemized deductions, CTC/ODC/ACTC, EITC, AOTC/LLC, dependent care credit, SE tax, QBI simplified, Social Security taxability, preferential capital-gain rates) and PA-40 (income classes, flat 3.07%, Schedule SP tax forgiveness) — with every value traceable to versioned rules and covered by regression tests. **No AI ever computes tax.** -- **Diagnostics** that block unsupported or inconsistent returns from filing -- **A 23-state filing lifecycle** with role-checked transitions, reviewer separation, and mandatory human review -- **Electronic signature** bound to the exact calculation snapshot; any material change invalidates signatures -- **Provider-neutral e-filing** through an authorized transmitter adapter (mock and sandbox providers today; Column Tax / april / other adapters slot in without touching business logic) -- **Hash-chained audit logging** of every sensitive action +The public website uses Next.js App Router, React, and TypeScript in `apps/web`. Pages render on the server; interactive workflow explorers and the enquiry form are small client components. Styling is local CSS with system fonts and no animation library. -## Repository layout +Requirements: Node.js 22 or newer, pnpm 10.33. +```sh +pnpm install --frozen-lockfile +pnpm dev ``` -apps/ - web/ Next.js application (GhimTech Tax UI) - api/ Fastify REST API - worker/ BullMQ background worker (ack polling, OCR, comms) -packages/ - tax-domain/ Normalized return model, lifecycle, permissions, money - tax-year-config/ Versioned per-year rule values (2025) - tax-engine-federal/ Deterministic Form 1040 engine - tax-engine-pennsylvania/ Deterministic PA-40 engine - forms-engine/ Form mapping, snapshots, printable documents - document-processing/ Upload hardening, OCR boundary, verification workflow - efile-core/ Provider contracts, orchestration, rejection dictionary - efile-providers/ Mock, sandbox, and placeholder transmitter adapters - database/ Prisma schema, migrations, seed - security/ Envelope encryption, masking, passwords, TOTP, tokens - audit/ Hash-chained audit events - validation/ Zod schemas and identifier validators - ui/ GhimTech Tax design-system components - testing/ Synthetic taxpayer fixtures (never real data) -infrastructure/ Dockerfiles -docs/ Architecture, security, tax rules, runbooks, ADRs + +Open http://localhost:3000. + +```sh +pnpm lint +pnpm typecheck +pnpm test +pnpm build +pnpm start ``` -## Quick start +## Configuration -Requirements: Node 22+, pnpm 10+, Docker (for Postgres/Redis). +Copy `.env.example` to `apps/web/.env.local` for local development. Configure production values through the hosting provider. -```bash -pnpm install -cp .env.example .env # generate the two keys as instructed inside +- `NEXT_PUBLIC_SITE_URL`: the canonical public origin, defaulting to https://ghimtech.org. +- `PROJECT_WEBHOOK_URL`: an HTTPS endpoint under your control that durably receives enquiries. +- `PROJECT_WEBHOOK_TOKEN`: server-only bearer credential for that endpoint. +- `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN`: shared Redis service for atomic rate limiting. +- `RATE_LIMIT_SECRET`: random server-only secret used to hash identifiers. -# Fastest path — no database needed (in-memory store, mock e-file provider): -GHIMTECH_STORE=memory pnpm --filter @ghimtech/api dev -pnpm --filter @ghimtech/web dev # in a second terminal → http://localhost:3000 +The form fails closed with an explicit, recoverable error when delivery or rate limiting is unconfigured. It does not claim to have received an enquiry when nothing has been delivered. -# Full stack with Postgres: -docker compose up -d -pnpm --filter @ghimtech/database db:migrate:dev -pnpm --filter @ghimtech/database db:seed # synthetic dev users + client -pnpm dev -``` +The receiving endpoint must validate the bearer token, honor the `Idempotency-Key` header for at least 24 hours, and acknowledge with 2xx only after durable receipt. Retries keep the same idempotency key for unchanged content. Do not expose these credentials through public environment variables. -Seeded development sign-ins (`GhimTechDev2026!`, password reset + MFA enrollment forced): `admin@dev.ghimtech.test`, `preparer@dev.ghimtech.test`, `reviewer@dev.ghimtech.test`, `client@dev.ghimtech.test`. +On Vercel, the endpoint uses the platform-controlled forwarded IP header. Other hosts use a shared request bucket until trusted proxy handling is explicitly configured. Rate limits are 20 attempts per request-identity bucket and 5 per normalized email address per hour. The application stores hashed rate-limit identifiers with a one-hour expiry. -## Verification +## Content -```bash -pnpm lint && pnpm typecheck && pnpm test && pnpm build -``` +- `apps/web/src/lib/content.ts`: service descriptions, projects, workflow modules, and articles. +- `apps/web/src/app/work/cyanjel-homecare/page.tsx`: flagship case study. +- `apps/web/src/app/globals.css`: responsive design system. -The test suite covers the tax engines against hand-computed 2025 scenarios, the full filing lifecycle end-to-end over the real API (including rejection → correction → resubmission), permission and MFA flows, encryption, upload security, and provider contract tests. +The case study separates confirmed customer positioning from illustrative workflow explanations. Specific product capabilities, real screenshots, and measured outcomes require source material or founder confirmation before publication. The project data model supports these fields without inventing them. -## Documentation +For a future case study, add its project data and route, then include it in the Work index. Sitemap entries are derived from the content list. -Start with [docs/architecture.md](docs/architecture.md). Security: [docs/security-model.md](docs/security-model.md) and [docs/threat-model.md](docs/threat-model.md). Tax rules and sources: [docs/tax-rules/](docs/tax-rules/). E-file integration: [docs/efile-providers.md](docs/efile-providers.md). Live status: [docs/PROGRESS.md](docs/PROGRESS.md). +## Deployment -## Important limits (enforced by blocking diagnostics) +The root `vercel.json` retains the existing `apps/web` output location. Use the repository root as the project root. Preview hosts must set `NEXT_PUBLIC_SITE_URL` to their own origin if the form is to be exercised there. -Unsupported situations cannot be e-filed — they surface as ERROR diagnostics and stop the pipeline. Highlights: part-year/nonresident PA returns (modeled, not filable), Schedule C losses or complex businesses, marketplace insurance reconciliation (Form 8962), Form 8995-A QBI, multi-state wages, MFS with Social Security. See [docs/tax-rules/](docs/tax-rules/) for the full list. +Set GHIMTECH_STANDALONE=1 when building a Linux self-hosted image to enable standalone output. Copy the standalone server, `public`, and `.next/static` into the runtime image as documented by Next.js. ---- +Read [release notes](docs/release-notes.md) before publishing. -© GhimTech. Author and maintainer: Durga Ghimeray. Private software — not licensed for redistribution. +© GhimTech. Author and maintainer: Durga Ghimeray. diff --git a/apps/api/package.json b/apps/api/package.json deleted file mode 100644 index 33081e0..0000000 --- a/apps/api/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "@ghimtech/api", - "version": "0.1.0", - "private": true, - "description": "GhimTech Tax REST API", - "author": "Durga Ghimeray", - "type": "module", - "main": "dist/index.js", - "scripts": { - "build": "tsc -p tsconfig.json", - "typecheck": "tsc -p tsconfig.json --noEmit", - "lint": "eslint src", - "test": "vitest run", - "test:coverage": "vitest run --coverage", - "dev": "tsx watch src/index.ts", - "start": "node dist/index.js" - }, - "dependencies": { - "@fastify/cors": "^10.0.2", - "@fastify/helmet": "^13.0.1", - "@fastify/rate-limit": "^10.2.2", - "@ghimtech/audit": "workspace:*", - "@ghimtech/database": "workspace:*", - "@ghimtech/document-processing": "workspace:*", - "@ghimtech/efile-core": "workspace:*", - "@ghimtech/efile-providers": "workspace:*", - "@ghimtech/forms-engine": "workspace:*", - "@ghimtech/security": "workspace:*", - "@ghimtech/tax-domain": "workspace:*", - "@ghimtech/tax-engine-federal": "workspace:*", - "@ghimtech/tax-engine-pennsylvania": "workspace:*", - "@ghimtech/tax-year-config": "workspace:*", - "@ghimtech/validation": "workspace:*", - "fastify": "^5.2.1", - "zod": "^3.24.1" - }, - "devDependencies": { - "@ghimtech/config": "workspace:*", - "@ghimtech/testing": "workspace:*", - "@types/node": "^22.10.7", - "tsx": "^4.19.2", - "typescript": "^5.7.3", - "vitest": "^3.0.4" - } -} diff --git a/apps/api/src/auth.test.ts b/apps/api/src/auth.test.ts deleted file mode 100644 index 55e091f..0000000 --- a/apps/api/src/auth.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { beforeAll, describe, expect, it } from "vitest"; -import { authed, buildTestServer, createUser, login, TEST_PASSWORD } from "./test-helpers.js"; -import type { MemoryStore } from "./store/memory.js"; -import type { FastifyInstance } from "fastify"; - -let app: FastifyInstance; -let store: MemoryStore; - -beforeAll(async () => { - const built = await buildTestServer(); - app = built.app; - store = built.store as MemoryStore; -}); - -describe("authentication", () => { - it("requires MFA before issuing a usable session", async () => { - const user = await createUser(store, "PREPARER"); - const loginRes = await app.inject({ - method: "POST", - url: "/auth/login", - payload: { email: user.email, password: TEST_PASSWORD }, - }); - expect(loginRes.statusCode).toBe(200); - const body = loginRes.json(); - expect(body.mfaRequired).toBe(true); - expect(body.token).toBeUndefined(); - - // The stage-1 token cannot access protected routes. - const denied = await app.inject({ - method: "GET", - url: "/auth/me", - headers: authed(body.mfaToken), - }); - expect(denied.statusCode).toBe(401); - }); - - it("completes MFA and returns recovery codes on first enrollment", async () => { - const user = await createUser(store, "PREPARER"); - const token = await login(app, user.email); - const me = await app.inject({ method: "GET", url: "/auth/me", headers: authed(token) }); - expect(me.statusCode).toBe(200); - expect(me.json().email).toBe(user.email); - }); - - it("rejects bad passwords uniformly and locks after repeated failures", async () => { - const user = await createUser(store, "PREPARER"); - for (let i = 0; i < 5; i++) { - const res = await app.inject({ - method: "POST", - url: "/auth/login", - payload: { email: user.email, password: "WrongPassword123" }, - }); - expect([401, 423]).toContain(res.statusCode); - } - const locked = await app.inject({ - method: "POST", - url: "/auth/login", - payload: { email: user.email, password: TEST_PASSWORD }, - }); - expect(locked.statusCode).toBe(423); - }); - - it("rejects wrong TOTP codes", async () => { - const user = await createUser(store, "REVIEWER"); - const loginRes = await app.inject({ - method: "POST", - url: "/auth/login", - payload: { email: user.email, password: TEST_PASSWORD }, - }); - const { mfaToken } = loginRes.json(); - const bad = await app.inject({ - method: "POST", - url: "/auth/mfa/verify", - payload: { mfaToken, code: "000001" }, - }); - expect(bad.statusCode).toBe(401); - }); - - it("revokes sessions on logout", async () => { - const user = await createUser(store, "PREPARER"); - const token = await login(app, user.email); - await app.inject({ method: "POST", url: "/auth/logout", headers: authed(token) }); - const after = await app.inject({ method: "GET", url: "/auth/me", headers: authed(token) }); - expect(after.statusCode).toBe(401); - }); - - it("rejects requests without a token", async () => { - const res = await app.inject({ method: "GET", url: "/clients" }); - expect(res.statusCode).toBe(401); - }); -}); - -describe("bootstrap admin", () => { - it("creates the initial admin on an empty store and can sign in", async () => { - const { loadConfig } = await import("./env.js"); - const { buildServer } = await import("./server.js"); - const { MemoryStore } = await import("./store/memory.js"); - const config = loadConfig({ - GHIMTECH_ENV: "test", - GHIMTECH_MASTER_KEY: "a".repeat(64), - GHIMTECH_INDEX_KEY: "b".repeat(64), - GHIMTECH_STORE: "memory", - GHIMTECH_BOOTSTRAP_ADMIN_EMAIL: "boot-admin@test.ghimtech.example", - GHIMTECH_BOOTSTRAP_ADMIN_PASSWORD: "BootstrapPass123", - } as NodeJS.ProcessEnv); - const freshStore = new MemoryStore(); - const built = await buildServer({ config, store: freshStore }); - const admin = await freshStore.getUserByEmail("boot-admin@test.ghimtech.example"); - expect(admin?.role).toBe("ADMIN"); - expect(admin?.passwordResetForced).toBe(true); - const res = await built.app.inject({ - method: "POST", - url: "/auth/login", - payload: { email: "boot-admin@test.ghimtech.example", password: "BootstrapPass123" }, - }); - expect(res.statusCode).toBe(200); - expect(res.json().mfaRequired).toBe(true); - - // A second boot with users present must not create anything. - await buildServer({ config, store: freshStore }); - expect(await freshStore.listUsers()).toHaveLength(1); - }); - - it("rejects weak bootstrap passwords", async () => { - const { loadConfig } = await import("./env.js"); - const { buildServer } = await import("./server.js"); - const { MemoryStore } = await import("./store/memory.js"); - const config = loadConfig({ - GHIMTECH_ENV: "test", - GHIMTECH_MASTER_KEY: "a".repeat(64), - GHIMTECH_INDEX_KEY: "b".repeat(64), - GHIMTECH_STORE: "memory", - GHIMTECH_BOOTSTRAP_ADMIN_EMAIL: "boot-admin@test.ghimtech.example", - GHIMTECH_BOOTSTRAP_ADMIN_PASSWORD: "weak", - } as NodeJS.ProcessEnv); - await expect(buildServer({ config, store: new MemoryStore() })).rejects.toThrow(/rejected/); - }); -}); - -describe("role permissions", () => { - it("denies client and preparer access to admin-only user management", async () => { - const preparer = await createUser(store, "PREPARER"); - const token = await login(app, preparer.email); - const res = await app.inject({ method: "GET", url: "/users", headers: authed(token) }); - expect(res.statusCode).toBe(403); - }); - - it("permission denials are audited", async () => { - const auditorUser = await createUser(store, "AUDITOR"); - const token = await login(app, auditorUser.email); - // Auditors cannot create clients… - const res = await app.inject({ - method: "POST", - url: "/clients", - headers: authed(token), - payload: {}, - }); - expect(res.statusCode).toBe(403); - // …but can read the audit log, where the denial has been chained. - const audit = await app.inject({ method: "GET", url: "/audit", headers: authed(token) }); - expect(audit.statusCode).toBe(200); - const events = audit.json() as Array<{ action: string }>; - expect(events.some((e) => e.action === "security.permission_denied")).toBe(true); - const verify = await app.inject({ - method: "GET", - url: "/audit/verify", - headers: authed(token), - }); - expect(verify.json().valid).toBe(true); - }); - - it("admin can create users; duplicates are rejected", async () => { - const admin = await createUser(store, "ADMIN"); - const token = await login(app, admin.email); - const created = await app.inject({ - method: "POST", - url: "/users", - headers: authed(token), - payload: { - email: "new-preparer@test.ghimtech.example", - name: "New Preparer", - role: "PREPARER", - temporaryPassword: "TemporaryPass123", - }, - }); - expect(created.statusCode).toBe(201); - const duplicate = await app.inject({ - method: "POST", - url: "/users", - headers: authed(token), - payload: { - email: "new-preparer@test.ghimtech.example", - name: "New Preparer", - role: "PREPARER", - temporaryPassword: "TemporaryPass123", - }, - }); - expect(duplicate.statusCode).toBe(409); - }); -}); diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts deleted file mode 100644 index aa199a6..0000000 --- a/apps/api/src/env.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * API configuration from the environment. Fails fast on missing secrets in - * production; generates ephemeral keys in development/test so a fresh clone - * runs without setup (with a clear warning). - */ -import { generateMasterKeyHex, parseMasterKey } from "@ghimtech/security"; - -export interface ApiConfig { - port: number; - host: string; - environment: "development" | "test" | "staging" | "production"; - masterKey: Buffer; - indexKey: Buffer; - /** Storage backend: in-memory for dev/test, Postgres via Prisma otherwise. */ - storeBackend: "memory" | "prisma"; - efileProvider: string; - sessionTtlMinutes: number; - corsOrigin: string; - /** - * First-run bootstrap: when the user table is empty, create this admin so a - * fresh deployment has a way to sign in. Password reset and MFA enrollment - * are forced at first login. Remove the variables after bootstrap. - */ - bootstrapAdmin?: { email: string; password: string }; -} - -export function loadConfig(env: NodeJS.ProcessEnv = process.env): ApiConfig { - const environment = (env.GHIMTECH_ENV ?? - env.NODE_ENV ?? - "development") as ApiConfig["environment"]; - const isProd = environment === "production" || environment === "staging"; - - let masterKeyHex = env.GHIMTECH_MASTER_KEY; - let indexKeyHex = env.GHIMTECH_INDEX_KEY; - if (!masterKeyHex || !indexKeyHex) { - if (isProd) { - throw new Error( - "GHIMTECH_MASTER_KEY and GHIMTECH_INDEX_KEY must be configured outside development", - ); - } - masterKeyHex = masterKeyHex ?? generateMasterKeyHex(); - indexKeyHex = indexKeyHex ?? generateMasterKeyHex(); - - console.warn( - "[ghimtech-api] Using ephemeral encryption keys — encrypted data will be unreadable after restart. Set GHIMTECH_MASTER_KEY / GHIMTECH_INDEX_KEY.", - ); - } - - const storeBackend = - (env.GHIMTECH_STORE as "memory" | "prisma" | undefined) ?? - (env.DATABASE_URL ? "prisma" : "memory"); - if (isProd && storeBackend !== "prisma") { - throw new Error("Production requires DATABASE_URL (Prisma store)"); - } - - const bootstrapAdmin = - env.GHIMTECH_BOOTSTRAP_ADMIN_EMAIL && env.GHIMTECH_BOOTSTRAP_ADMIN_PASSWORD - ? { - email: env.GHIMTECH_BOOTSTRAP_ADMIN_EMAIL, - password: env.GHIMTECH_BOOTSTRAP_ADMIN_PASSWORD, - } - : undefined; - - return { - bootstrapAdmin, - port: Number(env.PORT ?? 4000), - host: env.HOST ?? "0.0.0.0", - environment, - masterKey: parseMasterKey(masterKeyHex), - indexKey: Buffer.from(indexKeyHex, "hex"), - storeBackend, - efileProvider: env.GHIMTECH_EFILE_PROVIDER ?? "mock", - sessionTtlMinutes: Number(env.GHIMTECH_SESSION_TTL_MINUTES ?? 30), - corsOrigin: env.GHIMTECH_CORS_ORIGIN ?? "http://localhost:3000", - }; -} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts deleted file mode 100644 index a843329..0000000 --- a/apps/api/src/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { buildServer } from "./server.js"; - -const { app, config } = await buildServer(); - -try { - await app.listen({ port: config.port, host: config.host }); -} catch (err) { - app.log.error(err); - process.exit(1); -} diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts deleted file mode 100644 index cb583b1..0000000 --- a/apps/api/src/plugins/auth.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Authentication and authorization plugin. - * - * Bearer-token sessions: opaque tokens stored as SHA-256 hashes, short TTL, - * MFA-gated. Every protected route declares a required permission; client - * users are additionally scoped to their own records inside handlers via - * `requireOwnClient`. - */ -import type { FastifyInstance, FastifyReply, FastifyRequest, preHandlerHookHandler } from "fastify"; -import { hashToken } from "@ghimtech/security"; -import { roleHasPermission, type Permission, type Role } from "@ghimtech/tax-domain"; -import type { Store, UserRecord } from "../store/types.js"; -import type { AuditService } from "../services/audit-service.js"; - -export interface AuthContext { - user: UserRecord; - sessionId: string; -} - -declare module "fastify" { - interface FastifyRequest { - auth?: AuthContext; - } -} - -export interface AuthPluginOptions { - store: Store; - audit: AuditService; -} - -export function registerAuth(app: FastifyInstance, options: AuthPluginOptions): void { - app.decorateRequest("auth", undefined); - - app.decorate("authenticate", (async (request: FastifyRequest, reply: FastifyReply) => { - const header = request.headers.authorization; - if (!header?.startsWith("Bearer ")) { - return reply.code(401).send({ error: "Authentication required" }); - } - const token = header.slice("Bearer ".length).trim(); - const session = await options.store.getSessionByTokenHash(hashToken(token)); - if (!session || session.revokedAt || new Date(session.expiresAt) < new Date()) { - return reply.code(401).send({ error: "Session expired or revoked" }); - } - if (!session.mfaVerifiedAt) { - return reply.code(401).send({ error: "MFA verification required" }); - } - const user = await options.store.getUserById(session.userId); - if (!user || user.disabled) { - return reply.code(401).send({ error: "Account unavailable" }); - } - request.auth = { user, sessionId: session.id }; - }) satisfies preHandlerHookHandler); - - app.decorate("requirePermission", (permission: Permission): preHandlerHookHandler => { - return async (request: FastifyRequest, reply: FastifyReply) => { - await (app as FastifyInstance & { authenticate: preHandlerHookHandler }).authenticate.call( - app, - request, - reply, - () => undefined, - ); - if (reply.sent) return; - const role = request.auth!.user.role as Role; - if (!roleHasPermission(role, permission)) { - await options.audit.log({ - action: "security.permission_denied", - actorId: request.auth!.user.id, - actorRole: role, - details: { permission, path: request.url }, - ipAddress: request.ip, - }); - return reply.code(403).send({ error: `Missing permission: ${permission}` }); - } - }; - }); -} - -export type AuthenticatedApp = FastifyInstance & { - authenticate: preHandlerHookHandler; - requirePermission: (permission: Permission) => preHandlerHookHandler; -}; - -/** Resolve the client id a CLIENT-role user is allowed to access. */ -export async function requireOwnClient( - store: Store, - request: FastifyRequest, - reply: FastifyReply, -): Promise { - const client = await store.getClientByUserId(request.auth!.user.id); - if (!client) { - reply.code(403).send({ error: "No client profile linked to this account" }); - return undefined; - } - return client.id; -} diff --git a/apps/api/src/routes/auth-routes.ts b/apps/api/src/routes/auth-routes.ts deleted file mode 100644 index e412e41..0000000 --- a/apps/api/src/routes/auth-routes.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * Authentication routes: password login → mandatory TOTP MFA → session. - * First login enrolls MFA (secret returned once, over TLS, for authenticator - * apps). Brute force is limited by per-account lockout plus global rate - * limiting configured in server.ts. - */ -import { randomUUID } from "node:crypto"; -import type { FastifyInstance } from "fastify"; -import { - decryptField, - encryptField, - generateRecoveryCodes, - generateToken, - generateTotpSecret, - hashToken, - hashPassword, - otpauthUrl, - passwordMeetsPolicy, - verifyPassword, - verifyTotp, -} from "@ghimtech/security"; -import { loginSchema, mfaVerifySchema } from "@ghimtech/validation"; -import type { ApiConfig } from "../env.js"; -import type { AuthenticatedApp } from "../plugins/auth.js"; -import type { AuditService } from "../services/audit-service.js"; -import type { Store } from "../store/types.js"; - -const MAX_FAILED_LOGINS = 5; -const LOCKOUT_MINUTES = 15; - -export interface RouteContext { - store: Store; - audit: AuditService; - config: ApiConfig; -} - -export function registerAuthRoutes(app: FastifyInstance, ctx: RouteContext): void { - const typedApp = app as AuthenticatedApp; - const { store, audit, config } = ctx; - - app.post("/auth/login", async (request, reply) => { - const body = loginSchema.safeParse(request.body); - if (!body.success) return reply.code(400).send({ error: "Invalid credentials payload" }); - - const user = await store.getUserByEmail(body.data.email); - // Uniform failure path — do not reveal whether the account exists. - const fail = async () => { - if (user) { - const failed = user.failedLoginCount + 1; - await store.updateUser(user.id, { - failedLoginCount: failed, - ...(failed >= MAX_FAILED_LOGINS - ? { lockedUntil: new Date(Date.now() + LOCKOUT_MINUTES * 60_000).toISOString() } - : {}), - }); - await audit.log({ - action: failed >= MAX_FAILED_LOGINS ? "auth.login.locked" : "auth.login.failure", - actorId: user.id, - actorRole: user.role, - ipAddress: request.ip, - }); - } - return reply.code(401).send({ error: "Invalid email or password" }); - }; - - if (!user || user.disabled) return fail(); - if (user.lockedUntil && new Date(user.lockedUntil) > new Date()) { - return reply.code(423).send({ error: "Account temporarily locked. Try again later." }); - } - if (!(await verifyPassword(body.data.password, user.passwordHash))) return fail(); - - await store.updateUser(user.id, { failedLoginCount: 0, lockedUntil: undefined }); - - // Stage-1 session: not MFA-verified yet, short-lived. - const mfaToken = generateToken(); - await store.createSession({ - id: randomUUID(), - userId: user.id, - tokenHash: hashToken(mfaToken), - expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), - ipAddress: request.ip, - userAgent: request.headers["user-agent"], - }); - - if (!user.mfaEnrolled) { - // Mandatory enrollment: mint the TOTP secret now; verified on first MFA check. - const secret = generateTotpSecret(); - await store.updateUser(user.id, { - totpSecretEncrypted: encryptField(secret, config.masterKey), - }); - await audit.log({ - action: "auth.mfa.enrolled", - actorId: user.id, - actorRole: user.role, - ipAddress: request.ip, - }); - return reply.send({ - mfaRequired: true, - mfaEnrollment: { - secret, - otpauthUrl: otpauthUrl(secret, user.email), - }, - mfaToken, - }); - } - return reply.send({ mfaRequired: true, mfaToken }); - }); - - app.post("/auth/mfa/verify", async (request, reply) => { - const body = mfaVerifySchema.safeParse(request.body); - if (!body.success) return reply.code(400).send({ error: "Invalid MFA payload" }); - - const session = await store.getSessionByTokenHash(hashToken(body.data.mfaToken)); - if (!session || session.revokedAt || new Date(session.expiresAt) < new Date()) { - return reply.code(401).send({ error: "MFA session expired — sign in again" }); - } - const user = await store.getUserById(session.userId); - if (!user?.totpSecretEncrypted) { - return reply.code(401).send({ error: "MFA not initialized — sign in again" }); - } - const secret = decryptField(user.totpSecretEncrypted, config.masterKey); - if (!verifyTotp(secret, body.data.code, Date.now())) { - await audit.log({ - action: "auth.mfa.failed", - actorId: user.id, - actorRole: user.role, - ipAddress: request.ip, - }); - return reply.code(401).send({ error: "Incorrect code" }); - } - - let recoveryCodes: string[] | undefined; - if (!user.mfaEnrolled) { - recoveryCodes = generateRecoveryCodes(); - await store.updateUser(user.id, { - mfaEnrolled: true, - recoveryCodeHashes: recoveryCodes.map(hashToken), - }); - } - - // Upgrade to a full session with a fresh token (rotation). - const token = generateToken(); - await store.updateSession(session.id, { revokedAt: new Date().toISOString() }); - const fullSession = await store.createSession({ - id: randomUUID(), - userId: user.id, - tokenHash: hashToken(token), - expiresAt: new Date(Date.now() + config.sessionTtlMinutes * 60_000).toISOString(), - mfaVerifiedAt: new Date().toISOString(), - ipAddress: request.ip, - userAgent: request.headers["user-agent"], - }); - await audit.log({ - action: "auth.login.success", - actorId: user.id, - actorRole: user.role, - ipAddress: request.ip, - details: { sessionId: fullSession.id }, - }); - return reply.send({ - token, - user: { - id: user.id, - email: user.email, - name: user.name, - role: user.role, - passwordResetForced: user.passwordResetForced, - }, - ...(recoveryCodes ? { recoveryCodes } : {}), - }); - }); - - app.post("/auth/logout", { preHandler: [typedApp.authenticate] }, async (request, reply) => { - await store.updateSession(request.auth!.sessionId, { revokedAt: new Date().toISOString() }); - await audit.log({ - action: "auth.logout", - actorId: request.auth!.user.id, - actorRole: request.auth!.user.role, - ipAddress: request.ip, - }); - return reply.send({ ok: true }); - }); - - app.get("/auth/me", { preHandler: [typedApp.authenticate] }, async (request) => { - const { user } = request.auth!; - return { - id: user.id, - email: user.email, - name: user.name, - role: user.role, - passwordResetForced: user.passwordResetForced, - }; - }); - - app.post("/auth/password", { preHandler: [typedApp.authenticate] }, async (request, reply) => { - const body = request.body as { currentPassword?: string; newPassword?: string }; - if (!body?.currentPassword || !body?.newPassword) { - return reply.code(400).send({ error: "currentPassword and newPassword required" }); - } - const { user } = request.auth!; - if (!(await verifyPassword(body.currentPassword, user.passwordHash))) { - return reply.code(401).send({ error: "Current password is incorrect" }); - } - const policy = passwordMeetsPolicy(body.newPassword); - if (!policy.ok) return reply.code(400).send({ error: policy.reason }); - await store.updateUser(user.id, { - passwordHash: await hashPassword(body.newPassword), - passwordResetForced: false, - }); - await store.revokeUserSessions(user.id); - await audit.log({ - action: "auth.password.changed", - actorId: user.id, - actorRole: user.role, - ipAddress: request.ip, - }); - return reply.send({ ok: true, message: "Password changed. Sign in again." }); - }); -} diff --git a/apps/api/src/routes/document-routes.ts b/apps/api/src/routes/document-routes.ts deleted file mode 100644 index f0c19f9..0000000 --- a/apps/api/src/routes/document-routes.ts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * Document vault routes: hardened upload (magic-byte validation, size limit, - * malware scan), OCR, human verification, and audited access. Content is - * encrypted before storage; the dev store keeps ciphertext inline while - * production points at encrypted object storage. - */ -import { createHash, randomUUID } from "node:crypto"; -import type { FastifyInstance } from "fastify"; -import { - EicarTestScanner, - MockOcrEngine, - validateUpload, - type OcrResult, -} from "@ghimtech/document-processing"; -import { decryptField, encryptField } from "@ghimtech/security"; -import type { AuthenticatedApp } from "../plugins/auth.js"; -import { requireOwnClient } from "../plugins/auth.js"; -import type { RouteContext } from "./auth-routes.js"; - -export function registerDocumentRoutes(app: FastifyInstance, ctx: RouteContext): void { - const typedApp = app as AuthenticatedApp; - const { store, audit, config } = ctx; - const scanner = new EicarTestScanner(); - const ocrEngine = new MockOcrEngine(); - - app.post("/documents", { preHandler: [typedApp.authenticate] }, async (request, reply) => { - const role = request.auth!.user.role; - const body = request.body as { - clientId?: string; - returnId?: string; - filename?: string; - category?: string; - contentBase64?: string; - }; - if (!body?.filename || !body?.contentBase64) { - return reply.code(400).send({ error: "filename and contentBase64 are required" }); - } - - let clientId = body.clientId; - if (role === "CLIENT") { - const ownId = await requireOwnClient(store, request, reply); - if (!ownId) return; - clientId = ownId; - } else if (!["ADMIN", "PREPARER"].includes(role)) { - return reply.code(403).send({ error: "Access denied" }); - } - if (!clientId) return reply.code(400).send({ error: "clientId is required" }); - if (!(await store.getClient(clientId))) - return reply.code(404).send({ error: "Client not found" }); - - const content = Buffer.from(body.contentBase64, "base64"); - const validation = validateUpload({ - filename: body.filename, - sizeBytes: content.length, - head: content.subarray(0, 16), - }); - // Development convenience: .txt uploads exercise the mock OCR pipeline. - const isDevText = body.filename.endsWith(".txt") && config.environment !== "production"; - if (!validation.ok && !isDevText) { - return reply.code(422).send({ error: validation.reason }); - } - - const scan = await scanner.scan(content); - if (!scan.clean) { - await audit.log({ - action: "document.scan_flagged", - actorId: request.auth!.user.id, - actorRole: role, - entityType: "client", - entityId: clientId, - details: { signature: scan.signature ?? "unknown", filename: body.filename }, - }); - return reply.code(422).send({ error: "File failed the malware scan and was quarantined" }); - } - - const ocr: OcrResult = await ocrEngine.process(content, body.filename); - const doc = await store.createDocument({ - id: randomUUID(), - clientId, - returnId: body.returnId, - uploadedById: request.auth!.user.id, - category: body.category ?? ocr.category, - status: "PENDING_VERIFICATION", - filename: body.filename, - mimeType: validation.detectedMime ?? "text/plain", - sizeBytes: content.length, - sha256: createHash("sha256").update(content).digest("hex"), - contentEncrypted: encryptField(content.toString("base64"), config.masterKey), - ocr, - createdAt: new Date().toISOString(), - }); - await audit.log({ - action: "document.uploaded", - actorId: request.auth!.user.id, - actorRole: role, - entityType: "document", - entityId: doc.id, - details: { category: doc.category, sizeBytes: doc.sizeBytes }, - }); - return reply.code(201).send({ - id: doc.id, - category: doc.category, - status: doc.status, - ocr: { category: ocr.category, fields: ocr.fields }, - }); - }); - - app.get("/documents", { preHandler: [typedApp.authenticate] }, async (request, reply) => { - const role = request.auth!.user.role; - const query = request.query as { clientId?: string; returnId?: string }; - let clientId = query.clientId; - if (role === "CLIENT") { - const ownId = await requireOwnClient(store, request, reply); - if (!ownId) return; - clientId = ownId; - } else if (!["ADMIN", "PREPARER", "REVIEWER", "AUDITOR"].includes(role)) { - return reply.code(403).send({ error: "Access denied" }); - } - const docs = await store.listDocuments({ clientId, returnId: query.returnId }); - return docs.map((d) => ({ - id: d.id, - clientId: d.clientId, - category: d.category, - status: d.status, - filename: d.filename, - sizeBytes: d.sizeBytes, - createdAt: d.createdAt, - })); - }); - - app.post( - "/documents/:id/verify", - { preHandler: [typedApp.requirePermission("documents:write")] }, - async (request, reply) => { - const { id } = request.params as { id: string }; - const doc = await store.getDocument(id); - if (!doc) return reply.code(404).send({ error: "Document not found" }); - const body = request.body as { - categoryConfirmed?: boolean; - fields?: Array<{ key: string; verifiedValue: string }>; - }; - if (!body?.fields) return reply.code(400).send({ error: "fields are required" }); - const updated = await store.updateDocument(id, { - status: "VERIFIED", - verification: { - verifiedBy: request.auth!.user.id, - verifiedAt: new Date().toISOString(), - categoryConfirmed: body.categoryConfirmed ?? true, - fields: body.fields, - }, - }); - await audit.log({ - action: "document.verified", - actorId: request.auth!.user.id, - actorRole: request.auth!.user.role, - entityType: "document", - entityId: id, - }); - return { id: updated.id, status: updated.status }; - }, - ); - - app.get( - "/documents/:id/content", - { preHandler: [typedApp.authenticate] }, - async (request, reply) => { - const { id } = request.params as { id: string }; - const doc = await store.getDocument(id); - if (!doc) return reply.code(404).send({ error: "Document not found" }); - const role = request.auth!.user.role; - if (role === "CLIENT") { - const ownId = await requireOwnClient(store, request, reply); - if (!ownId || doc.clientId !== ownId) - return reply.code(403).send({ error: "Access denied" }); - } else if (!["ADMIN", "PREPARER", "REVIEWER", "AUDITOR"].includes(role)) { - return reply.code(403).send({ error: "Access denied" }); - } - if (!doc.contentEncrypted) { - return reply - .code(404) - .send({ error: "Content is stored in object storage; use a signed URL" }); - } - await audit.log({ - action: "document.downloaded", - actorId: request.auth!.user.id, - actorRole: role, - entityType: "document", - entityId: id, - }); - return { - filename: doc.filename, - mimeType: doc.mimeType, - contentBase64: decryptField(doc.contentEncrypted, config.masterKey), - }; - }, - ); -} diff --git a/apps/api/src/routes/efile-routes.ts b/apps/api/src/routes/efile-routes.ts deleted file mode 100644 index 9bd5021..0000000 --- a/apps/api/src/routes/efile-routes.ts +++ /dev/null @@ -1,289 +0,0 @@ -/** - * E-file routes. The submission path enforces, in order: correct lifecycle - * status, no blocking diagnostics, a valid signature matching the current - * snapshot, provider validation, then transmission — with each step audited - * and reflected in the return status. Duplicate submissions are impossible - * (registry keyed on return + snapshot hash). - */ -import { randomUUID } from "node:crypto"; -import type { FastifyInstance } from "fastify"; -import { - DuplicateSubmissionError, - EFileOrchestrator, - SubmissionBlockedError, - explainRejection, - type SubmissionRegistry, -} from "@ghimtech/efile-core"; -import { createProvider } from "@ghimtech/efile-providers"; -import { decryptField } from "@ghimtech/security"; -import type { AuthenticatedApp } from "../plugins/auth.js"; -import { ReturnService, ReturnServiceError } from "../services/return-service.js"; -import type { Store } from "../store/types.js"; -import type { RouteContext } from "./auth-routes.js"; - -class StoreSubmissionRegistry implements SubmissionRegistry { - constructor(private readonly store: Store) {} - async findBySnapshot(returnId: string, snapshotHash: string): Promise { - const existing = await this.store.findSubmissionBySnapshot(returnId, snapshotHash); - // A rejected submission may be corrected and resubmitted; anything else - // with the same snapshot is a duplicate. - if (!existing || existing.state === "REJECTED") return undefined; - return existing.providerSubmissionId; - } - async record(): Promise { - // Submissions are persisted as SubmissionRecords by the route handler. - } -} - -export function registerEfileRoutes(app: FastifyInstance, ctx: RouteContext): void { - const typedApp = app as AuthenticatedApp; - const { store, audit, config } = ctx; - const service = new ReturnService(store, audit); - const provider = createProvider(config.efileProvider); - const orchestrator = new EFileOrchestrator(provider, new StoreSubmissionRegistry(store)); - - app.post( - "/returns/:id/efile", - { preHandler: [typedApp.requirePermission("efile:submit")] }, - async (request, reply) => { - const { id } = request.params as { id: string }; - const actor = { id: request.auth!.user.id, role: request.auth!.user.role }; - try { - let ret = await service.getOrThrow(id); - const resubmitting = ret.status === "RESUBMISSION_READY"; - if (ret.status !== "READY_TO_EFILE" && !resubmitting) { - return reply.code(409).send({ - error: `Return must be READY_TO_EFILE or RESUBMISSION_READY (is ${ret.status})`, - }); - } - const snapshot = await service.latestSnapshot(ret); - if (!snapshot || !ret.latestSnapshotHash) { - return reply.code(409).send({ error: "No calculation snapshot" }); - } - const latestHash = ret.latestSnapshotHash; - const diagnostics = await service.currentDiagnostics(ret); - const signatures = await store.listSignatures(id); - const signature = signatures.find((s) => !s.invalidatedAt && s.snapshotHash === latestHash); - if (!signature) { - return reply - .code(409) - .send({ error: "No valid signature for the current return version" }); - } - - // Decrypt the TIN only here, at the transmission boundary. - const client = await store.getClient(ret.clientId); - if (!client) return reply.code(404).send({ error: "Client not found" }); - const tin = decryptField(client.tinEncrypted, config.masterKey); - - if (!resubmitting) { - ret = await service.transition(id, "VALIDATING", { id: "SYSTEM", role: "SYSTEM" }); - } - - const providerTaxpayer = await provider.createTaxpayer({ - externalClientRef: client.id, - firstName: client.firstName, - lastName: client.lastName, - tin, - dateOfBirth: client.dateOfBirth, - email: client.email, - }); - const providerReturn = await provider.createReturn({ - providerTaxpayerId: providerTaxpayer.providerTaxpayerId, - taxYear: ret.taxYear, - jurisdictions: ret.includePennsylvania ? ["FEDERAL", "PENNSYLVANIA"] : ["FEDERAL"], - returnModel: ret.model, - }); - - const validation = await provider.validateReturn(providerReturn.providerReturnId); - await audit.log({ - action: "efile.validated", - actorId: actor.id, - actorRole: actor.role, - entityType: "return", - entityId: id, - details: { valid: validation.valid, issues: validation.issues.length }, - }); - if (!validation.valid) { - if (!resubmitting) { - await service.transition(id, "VALIDATION_FAILED", { id: "SYSTEM", role: "SYSTEM" }); - } - return reply - .code(422) - .send({ error: "Provider validation failed", issues: validation.issues }); - } - - const providerAuth = await provider.generateAuthorization(providerReturn.providerReturnId); - const authorization = { - authorizationId: providerAuth.authorizationId, - signedSnapshotHash: signature.snapshotHash, - signedAt: signature.signedAt, - }; - - let receipt; - if (resubmitting) { - const previous = (await store.listSubmissions(id)) - .filter((s) => s.state === "REJECTED") - .at(-1); - if (!previous) - return reply.code(409).send({ error: "No rejected submission to correct" }); - receipt = await orchestrator.resubmit(previous.providerSubmissionId, { - returnId: id, - providerReturnId: providerReturn.providerReturnId, - diagnostics, - authorization, - currentSnapshotHash: latestHash, - correction: { - returnModel: ret.model, - addressedRejectionCodes: previous.acknowledgment?.rejections.map((r) => r.code) ?? [], - authorization, - }, - }); - await store.createSubmission({ - id: randomUUID(), - returnId: id, - jurisdiction: "FEDERAL", - provider: provider.name, - providerReturnId: providerReturn.providerReturnId, - providerSubmissionId: receipt.submissionId, - snapshotHash: latestHash, - state: "TRANSMITTED", - submittedById: actor.id, - submittedAt: new Date().toISOString(), - correctsSubmissionId: previous.id, - }); - await service.transition(id, "RESUBMITTED", { id: "SYSTEM", role: "SYSTEM" }); - await service.transition(id, "ACKNOWLEDGMENT_PENDING", { id: "SYSTEM", role: "SYSTEM" }); - await audit.log({ - action: "efile.resubmitted", - actorId: actor.id, - actorRole: actor.role, - entityType: "return", - entityId: id, - details: { submissionId: receipt.submissionId }, - }); - } else { - await service.transition(id, "QUEUED_FOR_TRANSMISSION", { id: "SYSTEM", role: "SYSTEM" }); - await service.transition(id, "TRANSMITTING", { id: "SYSTEM", role: "SYSTEM" }); - receipt = await orchestrator.submit({ - returnId: id, - providerReturnId: providerReturn.providerReturnId, - diagnostics, - authorization, - currentSnapshotHash: latestHash, - }); - await store.createSubmission({ - id: randomUUID(), - returnId: id, - jurisdiction: "FEDERAL", - provider: provider.name, - providerReturnId: providerReturn.providerReturnId, - providerSubmissionId: receipt.submissionId, - snapshotHash: latestHash, - state: "TRANSMITTED", - submittedById: actor.id, - submittedAt: new Date().toISOString(), - }); - await service.transition(id, "TRANSMITTED", { id: "SYSTEM", role: "SYSTEM" }); - await service.transition(id, "ACKNOWLEDGMENT_PENDING", { id: "SYSTEM", role: "SYSTEM" }); - await audit.log({ - action: "efile.transmitted", - actorId: actor.id, - actorRole: actor.role, - entityType: "return", - entityId: id, - details: { submissionId: receipt.submissionId, provider: provider.name }, - }); - } - return reply.code(202).send({ - submissionId: receipt.submissionId, - state: receipt.state, - provider: provider.name, - }); - } catch (err) { - const e = err as Error; - if (e instanceof SubmissionBlockedError) return reply.code(409).send({ error: e.message }); - if (e instanceof DuplicateSubmissionError) { - return reply.code(409).send({ error: e.message }); - } - if (e instanceof ReturnServiceError) - return reply.code(e.statusCode).send({ error: e.message }); - if (e.name === "ProviderNotConfiguredError") { - return reply.code(503).send({ error: e.message }); - } - throw err; - } - }, - ); - - /** Poll the provider for the acknowledgment; idempotent. */ - app.post( - "/returns/:id/efile/poll", - { preHandler: [typedApp.requirePermission("efile:read")] }, - async (request, reply) => { - const { id } = request.params as { id: string }; - const ret = await store.getReturn(id); - if (!ret) return reply.code(404).send({ error: "Return not found" }); - const submissions = await store.listSubmissions(id); - const pending = submissions.filter( - (s) => s.state === "TRANSMITTED" || s.state === "QUEUED" || s.state === "TRANSMITTING", - ); - if (pending.length === 0) { - return { status: ret.status, submissions }; - } - for (const submission of pending) { - const ack = await orchestrator.pollAcknowledgment(submission.providerSubmissionId); - if (!ack) continue; - await store.updateSubmission(submission.id, { - state: ack.accepted ? "ACCEPTED" : "REJECTED", - resolvedAt: new Date().toISOString(), - acknowledgment: { - accepted: ack.accepted, - agencyTrackingId: ack.agencyTrackingId, - rejections: ack.rejections.map((r) => ({ - ...r, - ...explainRejection(r.code), - })), - acknowledgedAt: ack.acknowledgedAt, - }, - }); - await audit.log({ - action: ack.accepted ? "efile.acknowledged" : "efile.rejected", - actorId: "SYSTEM", - actorRole: "SYSTEM", - entityType: "return", - entityId: id, - details: { - submissionId: submission.providerSubmissionId, - accepted: ack.accepted, - trackingId: ack.agencyTrackingId ?? null, - }, - }); - if (ret.status === "ACKNOWLEDGMENT_PENDING") { - if (ack.accepted) { - await new ReturnService(store, audit).transition(id, "ACCEPTED", { - id: "SYSTEM", - role: "SYSTEM", - }); - } else { - const svc = new ReturnService(store, audit); - await svc.transition(id, "REJECTED", { id: "SYSTEM", role: "SYSTEM" }); - await svc.transition(id, "CORRECTION_REQUIRED", { id: "SYSTEM", role: "SYSTEM" }); - } - } - } - const updatedReturn = await store.getReturn(id); - return { status: updatedReturn!.status, submissions: await store.listSubmissions(id) }; - }, - ); - - app.get( - "/returns/:id/submissions", - { preHandler: [typedApp.requirePermission("efile:read")] }, - async (request, reply) => { - const { id } = request.params as { id: string }; - const ret = await store.getReturn(id); - if (!ret) return reply.code(404).send({ error: "Return not found" }); - return store.listSubmissions(id); - }, - ); -} diff --git a/apps/api/src/routes/platform-routes.ts b/apps/api/src/routes/platform-routes.ts deleted file mode 100644 index b9702af..0000000 --- a/apps/api/src/routes/platform-routes.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Platform routes: health, dashboard aggregates, audit access, and reporting. - */ -import type { FastifyInstance } from "fastify"; -import { RETURN_STATUSES } from "@ghimtech/tax-domain"; -import { supportedTaxYears } from "@ghimtech/tax-year-config"; -import type { AuthenticatedApp } from "../plugins/auth.js"; -import type { RouteContext } from "./auth-routes.js"; - -export function registerPlatformRoutes(app: FastifyInstance, ctx: RouteContext): void { - const typedApp = app as AuthenticatedApp; - const { store, audit } = ctx; - - app.get("/health", async () => ({ - ok: true, - service: "ghimtech-tax-api", - supportedTaxYears: supportedTaxYears(), - })); - - app.get("/dashboard", { preHandler: [typedApp.requirePermission("reports:read")] }, async () => { - const returns = await store.listReturns(); - const byStatus: Record = {}; - for (const status of RETURN_STATUSES) byStatus[status] = 0; - for (const ret of returns) byStatus[ret.status] = (byStatus[ret.status] ?? 0) + 1; - - const clients = await store.listClients(); - const accepted = byStatus["ACCEPTED"]! + byStatus["ARCHIVED"]!; - const rejected = byStatus["REJECTED"]! + byStatus["CORRECTION_REQUIRED"]!; - const resolved = accepted + rejected; - return { - returnsByStatus: byStatus, - totals: { - clients: clients.length, - returns: returns.length, - awaitingReview: byStatus["READY_FOR_REVIEWER"], - awaitingSignature: byStatus["AWAITING_SIGNATURE"], - readyToFile: byStatus["READY_TO_EFILE"], - transmitted: byStatus["TRANSMITTED"]! + byStatus["ACKNOWLEDGMENT_PENDING"]!, - accepted, - rejected, - acceptanceRate: resolved === 0 ? null : Math.round((accepted / resolved) * 100) / 100, - }, - }; - }); - - app.get("/audit", { preHandler: [typedApp.requirePermission("audit:read")] }, async (request) => { - const query = request.query as { entityId?: string; limit?: string }; - const events = await store.listAuditEvents({ - entityId: query.entityId, - limit: query.limit ? Number(query.limit) : 200, - }); - return events; - }); - - app.get("/audit/verify", { preHandler: [typedApp.requirePermission("audit:read")] }, async () => - audit.verify(), - ); -} diff --git a/apps/api/src/routes/return-routes.ts b/apps/api/src/routes/return-routes.ts deleted file mode 100644 index 61c59b8..0000000 --- a/apps/api/src/routes/return-routes.ts +++ /dev/null @@ -1,367 +0,0 @@ -/** - * Return routes: creation, model editing, calculation, lifecycle transitions, - * review packages, and signature capture. - */ -import { createHash, randomUUID } from "node:crypto"; -import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; -import { buildReturnPackage, renderFormHtml } from "@ghimtech/forms-engine"; -import { encryptField } from "@ghimtech/security"; -import { - allowedTransitions, - emptyReturnModel, - type Role, - type TaxReturnModel, -} from "@ghimtech/tax-domain"; -import { - createReturnSchema, - signatureCaptureSchema, - statusTransitionSchema, -} from "@ghimtech/validation"; -import type { AuthenticatedApp } from "../plugins/auth.js"; -import { requireOwnClient } from "../plugins/auth.js"; -import { ReturnService, ReturnServiceError } from "../services/return-service.js"; -import type { ReturnRecord } from "../store/types.js"; -import type { RouteContext } from "./auth-routes.js"; - -function returnView(ret: ReturnRecord, role: Role) { - return { - id: ret.id, - clientId: ret.clientId, - taxYear: ret.taxYear, - status: ret.status, - includePennsylvania: ret.includePennsylvania, - latestSnapshotHash: ret.latestSnapshotHash, - allowedTransitions: allowedTransitions(ret.status, role), - model: ret.model, - createdAt: ret.createdAt, - updatedAt: ret.updatedAt, - }; -} - -export function registerReturnRoutes(app: FastifyInstance, ctx: RouteContext): void { - const typedApp = app as AuthenticatedApp; - const { store, audit, config } = ctx; - const service = new ReturnService(store, audit); - - async function guardClientScope( - request: FastifyRequest, - reply: FastifyReply, - ret: ReturnRecord, - ): Promise { - if (request.auth!.user.role !== "CLIENT") return true; - const ownId = await requireOwnClient(store, request, reply); - if (!ownId) return false; - if (ret.clientId !== ownId) { - reply.code(403).send({ error: "Access denied" }); - return false; - } - return true; - } - - app.post( - "/returns", - { preHandler: [typedApp.requirePermission("returns:write")] }, - async (request, reply) => { - const body = createReturnSchema.safeParse(request.body); - if (!body.success) return reply.code(400).send({ error: body.error.issues[0]?.message }); - const client = await store.getClient(body.data.clientId); - if (!client) return reply.code(404).send({ error: "Client not found" }); - - const model = emptyReturnModel({ - returnId: randomUUID(), - taxYear: body.data.taxYear, - filingStatus: body.data.filingStatus, - taxpayer: { - name: { firstName: client.firstName, lastName: client.lastName }, - ssnRef: `client:${client.id}`, - ssnLast4: client.tinLast4, - dateOfBirth: client.dateOfBirth, - isBlind: false, - }, - address: client.address, - }); - if (body.data.includePennsylvania) { - model.pennsylvania = { - residencyStatus: "FULL_YEAR_RESIDENT", - claimTaxForgiveness: false, - spEligibilityOtherIncome: 0, - }; - } - try { - const ret = await store.createReturn({ - id: model.returnId, - clientId: client.id, - taxYear: body.data.taxYear, - status: "DRAFT", - includePennsylvania: body.data.includePennsylvania, - model, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - await audit.log({ - action: "return.created", - actorId: request.auth!.user.id, - actorRole: request.auth!.user.role, - entityType: "return", - entityId: ret.id, - details: { taxYear: ret.taxYear, clientId: client.id }, - }); - return reply.code(201).send(returnView(ret, request.auth!.user.role)); - } catch (err) { - return reply.code(409).send({ error: (err as Error).message }); - } - }, - ); - - app.get("/returns", { preHandler: [typedApp.authenticate] }, async (request, reply) => { - const role = request.auth!.user.role; - const query = request.query as { clientId?: string; status?: string }; - if (role === "CLIENT") { - const ownId = await requireOwnClient(store, request, reply); - if (!ownId) return; - const returns = await store.listReturns({ clientId: ownId }); - return returns.map((r) => returnView(r, role)); - } - if (!["ADMIN", "PREPARER", "REVIEWER", "AUDITOR"].includes(role)) { - return reply.code(403).send({ error: "Access denied" }); - } - const returns = await store.listReturns({ - clientId: query.clientId, - status: query.status as ReturnRecord["status"] | undefined, - }); - return returns.map((r) => returnView(r, role)); - }); - - app.get("/returns/:id", { preHandler: [typedApp.authenticate] }, async (request, reply) => { - const { id } = request.params as { id: string }; - try { - const ret = await service.getOrThrow(id); - if (!(await guardClientScope(request, reply, ret))) return; - return returnView(ret, request.auth!.user.role); - } catch (err) { - if (err instanceof ReturnServiceError) - return reply.code(err.statusCode).send({ error: err.message }); - throw err; - } - }); - - app.put( - "/returns/:id/model", - { preHandler: [typedApp.requirePermission("returns:write")] }, - async (request, reply) => { - const { id } = request.params as { id: string }; - try { - const model = request.body as TaxReturnModel; - if (!model || model.returnId !== id) { - return reply.code(400).send({ error: "Model returnId must match the URL" }); - } - const updated = await service.updateModel(id, model, { - id: request.auth!.user.id, - role: request.auth!.user.role, - }); - return returnView(updated, request.auth!.user.role); - } catch (err) { - if (err instanceof ReturnServiceError) - return reply.code(err.statusCode).send({ error: err.message }); - throw err; - } - }, - ); - - app.post( - "/returns/:id/calculate", - { preHandler: [typedApp.requirePermission("returns:calculate")] }, - async (request, reply) => { - const { id } = request.params as { id: string }; - try { - const outcome = await service.calculate(id, { - id: request.auth!.user.id, - role: request.auth!.user.role, - }); - return outcome; - } catch (err) { - if (err instanceof ReturnServiceError) - return reply.code(err.statusCode).send({ error: err.message }); - if ((err as Error).name === "UnsupportedTaxYearError") { - return reply.code(422).send({ error: (err as Error).message }); - } - throw err; - } - }, - ); - - app.post( - "/returns/:id/transition", - { preHandler: [typedApp.authenticate] }, - async (request, reply) => { - const { id } = request.params as { id: string }; - const body = statusTransitionSchema.safeParse(request.body); - if (!body.success) return reply.code(400).send({ error: body.error.issues[0]?.message }); - try { - const ret = await service.getOrThrow(id); - if (!(await guardClientScope(request, reply, ret))) return; - const updated = await service.transition( - id, - body.data.toStatus, - { - id: request.auth!.user.id, - role: request.auth!.user.role, - }, - { note: body.data.note }, - ); - return returnView(updated, request.auth!.user.role); - } catch (err) { - const e = err as Error; - if (e instanceof ReturnServiceError) - return reply.code(e.statusCode).send({ error: e.message }); - if (e.name === "IllegalTransitionError") return reply.code(409).send({ error: e.message }); - if (e.name === "TransitionPermissionError") - return reply.code(403).send({ error: e.message }); - throw err; - } - }, - ); - - app.get( - "/returns/:id/history", - { preHandler: [typedApp.authenticate] }, - async (request, reply) => { - const { id } = request.params as { id: string }; - const ret = await store.getReturn(id); - if (!ret) return reply.code(404).send({ error: "Return not found" }); - if (!(await guardClientScope(request, reply, ret))) return; - return store.listStatusEvents(id); - }, - ); - - app.get( - "/returns/:id/package", - { preHandler: [typedApp.authenticate] }, - async (request, reply) => { - const { id } = request.params as { id: string }; - const query = request.query as { watermark?: string; format?: string }; - const ret = await store.getReturn(id); - if (!ret) return reply.code(404).send({ error: "Return not found" }); - if (!(await guardClientScope(request, reply, ret))) return; - const snapshot = await service.latestSnapshot(ret); - if (!snapshot) return reply.code(409).send({ error: "Run calculations first" }); - - const role = request.auth!.user.role; - const watermark = - ret.status === "ACCEPTED" || ret.status === "ARCHIVED" - ? "CLIENT_COPY" - : role === "CLIENT" - ? "CLIENT_COPY" - : ((query.watermark as "DRAFT" | "REVIEW_COPY") ?? "DRAFT"); - const pkg = buildReturnPackage(snapshot, watermark); - await audit.log({ - action: "return.viewed", - actorId: request.auth!.user.id, - actorRole: role, - entityType: "return", - entityId: id, - details: { package: watermark }, - }); - if (query.format === "html") { - reply.type("text/html"); - return pkg.documents - .map(renderFormHtml) - .join('\n
\n'); - } - return pkg; - }, - ); - - // ---- Signature capture ---- - app.post( - "/returns/:id/signatures", - { preHandler: [typedApp.requirePermission("returns:sign")] }, - async (request, reply) => { - const { id } = request.params as { id: string }; - const body = signatureCaptureSchema.safeParse(request.body); - if (!body.success) return reply.code(400).send({ error: body.error.issues[0]?.message }); - try { - const ret = await service.getOrThrow(id); - if (!(await guardClientScope(request, reply, ret))) return; - if (ret.status !== "AWAITING_SIGNATURE") { - return reply - .code(409) - .send({ error: `Return is not awaiting signature (status ${ret.status})` }); - } - if (!ret.latestSnapshotHash || body.data.reviewedSnapshotHash !== ret.latestSnapshotHash) { - return reply.code(409).send({ - error: - "The reviewed return version does not match the current calculation — review again before signing", - }); - } - const signedAt = new Date().toISOString(); - const certificateHash = createHash("sha256") - .update(`${request.auth!.user.id}|${ret.latestSnapshotHash}|${signedAt}`) - .digest("hex"); - const signature = await store.createSignature({ - id: randomUUID(), - returnId: id, - signerId: request.auth!.user.id, - role: "TAXPAYER", - snapshotHash: ret.latestSnapshotHash, - payloadEncrypted: encryptField( - JSON.stringify({ signatureText: body.data.signatureText, consent: true, signedAt }), - config.masterKey, - ), - certificateHash, - signedAt, - ipAddress: request.ip, - userAgent: request.headers["user-agent"], - }); - await audit.log({ - action: "signature.captured", - actorId: request.auth!.user.id, - actorRole: request.auth!.user.role, - entityType: "return", - entityId: id, - details: { certificateHash, snapshotHash: ret.latestSnapshotHash }, - }); - const updated = await service.transition( - id, - "SIGNED", - { id: request.auth!.user.id, role: request.auth!.user.role }, - { viaSignatureService: true }, - ); - return reply.code(201).send({ - signatureId: signature.id, - certificateHash, - status: updated.status, - }); - } catch (err) { - const e = err as Error; - if (e instanceof ReturnServiceError) - return reply.code(e.statusCode).send({ error: e.message }); - if (e.name === "TransitionPermissionError") - return reply.code(403).send({ error: e.message }); - throw err; - } - }, - ); - - app.get( - "/returns/:id/signatures", - { preHandler: [typedApp.authenticate] }, - async (request, reply) => { - const { id } = request.params as { id: string }; - const ret = await store.getReturn(id); - if (!ret) return reply.code(404).send({ error: "Return not found" }); - if (!(await guardClientScope(request, reply, ret))) return; - const signatures = await store.listSignatures(id); - return signatures.map((s) => ({ - id: s.id, - signerId: s.signerId, - role: s.role, - snapshotHash: s.snapshotHash, - certificateHash: s.certificateHash, - signedAt: s.signedAt, - invalidatedAt: s.invalidatedAt, - invalidatedReason: s.invalidatedReason, - })); - }, - ); -} diff --git a/apps/api/src/routes/user-client-routes.ts b/apps/api/src/routes/user-client-routes.ts deleted file mode 100644 index 47834fd..0000000 --- a/apps/api/src/routes/user-client-routes.ts +++ /dev/null @@ -1,215 +0,0 @@ -/** - * User management (admin) and client management routes. - */ -import { randomUUID } from "node:crypto"; -import type { FastifyInstance } from "fastify"; -import { blindIndex, encryptField, hashPassword, maskSsn } from "@ghimtech/security"; -import { createClientSchema, createUserSchema } from "@ghimtech/validation"; -import type { AuthenticatedApp } from "../plugins/auth.js"; -import { requireOwnClient } from "../plugins/auth.js"; -import type { ClientRecord } from "../store/types.js"; -import type { RouteContext } from "./auth-routes.js"; - -function clientView(client: ClientRecord) { - return { - id: client.id, - firstName: client.firstName, - lastName: client.lastName, - email: client.email, - phone: client.phone, - tags: client.tags, - tinMasked: maskSsn(client.tinLast4), - dateOfBirth: client.dateOfBirth, - address: client.address, - assignedPreparerId: client.assignedPreparerId, - createdAt: client.createdAt, - }; -} - -export function registerUserAndClientRoutes(app: FastifyInstance, ctx: RouteContext): void { - const typedApp = app as AuthenticatedApp; - const { store, audit, config } = ctx; - - // ---- Users (admin only) ---- - app.post( - "/users", - { preHandler: [typedApp.requirePermission("users:manage")] }, - async (request, reply) => { - const body = createUserSchema.safeParse(request.body); - if (!body.success) return reply.code(400).send({ error: body.error.issues[0]?.message }); - const existing = await store.getUserByEmail(body.data.email); - if (existing) return reply.code(409).send({ error: "A user with this email already exists" }); - const user = await store.createUser({ - id: randomUUID(), - email: body.data.email, - name: body.data.name, - role: body.data.role, - passwordHash: await hashPassword(body.data.temporaryPassword), - passwordResetForced: true, - mfaEnrolled: false, - recoveryCodeHashes: [], - failedLoginCount: 0, - disabled: false, - }); - await audit.log({ - action: "user.created", - actorId: request.auth!.user.id, - actorRole: request.auth!.user.role, - entityType: "user", - entityId: user.id, - details: { role: user.role }, - }); - return reply.code(201).send({ id: user.id, email: user.email, role: user.role }); - }, - ); - - app.get("/users", { preHandler: [typedApp.requirePermission("users:manage")] }, async () => { - const users = await store.listUsers(); - return users.map((u) => ({ - id: u.id, - email: u.email, - name: u.name, - role: u.role, - disabled: u.disabled, - mfaEnrolled: u.mfaEnrolled, - })); - }); - - app.post( - "/users/:id/disable", - { preHandler: [typedApp.requirePermission("users:manage")] }, - async (request, reply) => { - const { id } = request.params as { id: string }; - if (id === request.auth!.user.id) { - return reply.code(400).send({ error: "You cannot disable your own account" }); - } - await store.updateUser(id, { disabled: true }); - await store.revokeUserSessions(id); - await audit.log({ - action: "user.disabled", - actorId: request.auth!.user.id, - actorRole: request.auth!.user.role, - entityType: "user", - entityId: id, - }); - return { ok: true }; - }, - ); - - // ---- Clients ---- - app.post( - "/clients", - { preHandler: [typedApp.requirePermission("clients:write")] }, - async (request, reply) => { - const body = createClientSchema.safeParse(request.body); - if (!body.success) return reply.code(400).send({ error: body.error.issues[0]?.message }); - const tinIdx = blindIndex(body.data.tin, config.indexKey); - const duplicate = await store.getClientByTinIndex(tinIdx); - if (duplicate) { - return reply.code(409).send({ error: "A client with this SSN/ITIN already exists" }); - } - const client = await store.createClient({ - id: randomUUID(), - assignedPreparerId: body.data.assignedPreparerId ?? request.auth!.user.id, - firstName: body.data.name.firstName, - lastName: body.data.name.lastName, - email: body.data.email, - phone: body.data.phone, - tags: body.data.tags, - tinEncrypted: encryptField(body.data.tin, config.masterKey), - tinLast4: body.data.tin.slice(-4), - tinIndex: tinIdx, - dateOfBirth: body.data.dateOfBirth, - address: body.data.address, - createdAt: new Date().toISOString(), - }); - await audit.log({ - action: "client.created", - actorId: request.auth!.user.id, - actorRole: request.auth!.user.role, - entityType: "client", - entityId: client.id, - details: { tinMasked: maskSsn(client.tinLast4) }, - }); - return reply.code(201).send(clientView(client)); - }, - ); - - app.get("/clients", { preHandler: [typedApp.requirePermission("clients:read")] }, async () => { - return (await store.listClients()).map(clientView); - }); - - app.get( - "/clients/me", - { preHandler: [typedApp.requirePermission("clients:read-own")] }, - async (request, reply) => { - const clientId = await requireOwnClient(store, request, reply); - if (!clientId) return; - const client = await store.getClient(clientId); - return clientView(client!); - }, - ); - - app.get("/clients/:id", { preHandler: [typedApp.authenticate] }, async (request, reply) => { - const { id } = request.params as { id: string }; - const role = request.auth!.user.role; - if (role === "CLIENT") { - const ownId = await requireOwnClient(store, request, reply); - if (!ownId) return; - if (ownId !== id) return reply.code(403).send({ error: "Access denied" }); - } else if (!["ADMIN", "PREPARER", "REVIEWER", "AUDITOR"].includes(role)) { - return reply.code(403).send({ error: "Access denied" }); - } - const client = await store.getClient(id); - if (!client) return reply.code(404).send({ error: "Client not found" }); - await audit.log({ - action: "client.viewed", - actorId: request.auth!.user.id, - actorRole: role, - entityType: "client", - entityId: id, - }); - return clientView(client); - }); - - // Link a portal login for an existing client (admin/preparer). - app.post( - "/clients/:id/portal-account", - { preHandler: [typedApp.requirePermission("clients:write")] }, - async (request, reply) => { - const { id } = request.params as { id: string }; - const body = request.body as { temporaryPassword?: string }; - const client = await store.getClient(id); - if (!client) return reply.code(404).send({ error: "Client not found" }); - if (client.userId) - return reply.code(409).send({ error: "Client already has a portal account" }); - if (!body?.temporaryPassword || body.temporaryPassword.length < 12) { - return reply - .code(400) - .send({ error: "temporaryPassword of at least 12 characters required" }); - } - const user = await store.createUser({ - id: randomUUID(), - email: client.email, - name: `${client.firstName} ${client.lastName}`, - role: "CLIENT", - passwordHash: await hashPassword(body.temporaryPassword), - passwordResetForced: true, - mfaEnrolled: false, - recoveryCodeHashes: [], - failedLoginCount: 0, - disabled: false, - }); - await store.updateClient(id, { userId: user.id }); - await audit.log({ - action: "user.created", - actorId: request.auth!.user.id, - actorRole: request.auth!.user.role, - entityType: "user", - entityId: user.id, - details: { role: "CLIENT", clientId: id }, - }); - return reply.code(201).send({ userId: user.id }); - }, - ); -} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts deleted file mode 100644 index b85dd6b..0000000 --- a/apps/api/src/server.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * API server assembly. Secure headers, CORS restricted to the web app - * origin, global rate limiting, and structured error handling that never - * leaks internals or sensitive values. - */ -import Fastify, { type FastifyInstance } from "fastify"; -import cors from "@fastify/cors"; -import helmet from "@fastify/helmet"; -import rateLimit from "@fastify/rate-limit"; -import { scrubSensitive } from "@ghimtech/security"; -import { loadConfig, type ApiConfig } from "./env.js"; -import { registerAuth } from "./plugins/auth.js"; -import { AuditService } from "./services/audit-service.js"; -import { registerAuthRoutes, type RouteContext } from "./routes/auth-routes.js"; -import { registerUserAndClientRoutes } from "./routes/user-client-routes.js"; -import { registerReturnRoutes } from "./routes/return-routes.js"; -import { registerEfileRoutes } from "./routes/efile-routes.js"; -import { registerDocumentRoutes } from "./routes/document-routes.js"; -import { registerPlatformRoutes } from "./routes/platform-routes.js"; -import { MemoryStore } from "./store/memory.js"; -import type { Store } from "./store/types.js"; - -export interface BuildOptions { - config?: ApiConfig; - store?: Store; -} - -export async function buildServer(options: BuildOptions = {}): Promise<{ - app: FastifyInstance; - store: Store; - config: ApiConfig; - audit: AuditService; -}> { - const config = options.config ?? loadConfig(); - let store = options.store; - if (!store) { - if (config.storeBackend === "prisma") { - const { PrismaStore } = await import("./store/prisma.js"); - const prismaStore = new PrismaStore(); - await prismaStore.ensureOrganization(); - store = prismaStore; - } else { - store = new MemoryStore(); - } - } - - const app = Fastify({ - logger: config.environment !== "test" && { - level: "info", - redact: { - paths: ["req.headers.authorization", "*.password", "*.tin", "*.contentBase64"], - censor: "[redacted]", - }, - }, - bodyLimit: 40 * 1024 * 1024, - trustProxy: true, - }); - - await app.register(helmet, { contentSecurityPolicy: false }); - await app.register(cors, { - origin: config.corsOrigin.split(","), - credentials: false, - methods: ["GET", "POST", "PUT", "DELETE"], - }); - await app.register(rateLimit, { - max: config.environment === "test" ? 10_000 : 300, - timeWindow: "1 minute", - }); - - const audit = new AuditService(store); - - // First-run bootstrap: create the initial administrator on an empty user - // table. Password reset + MFA enrollment are forced at first sign-in. - if (config.bootstrapAdmin) { - const existing = await store.listUsers(); - if (existing.length === 0) { - const { hashPassword, passwordMeetsPolicy } = await import("@ghimtech/security"); - const policy = passwordMeetsPolicy(config.bootstrapAdmin.password); - if (!policy.ok) { - throw new Error(`GHIMTECH_BOOTSTRAP_ADMIN_PASSWORD rejected: ${policy.reason}`); - } - const { randomUUID } = await import("node:crypto"); - const admin = await store.createUser({ - id: randomUUID(), - email: config.bootstrapAdmin.email, - name: "Administrator", - role: "ADMIN", - passwordHash: await hashPassword(config.bootstrapAdmin.password), - passwordResetForced: true, - mfaEnrolled: false, - recoveryCodeHashes: [], - failedLoginCount: 0, - disabled: false, - }); - await audit.log({ - action: "user.created", - actorId: "SYSTEM", - actorRole: "SYSTEM", - entityType: "user", - entityId: admin.id, - details: { bootstrap: true, role: "ADMIN" }, - }); - } - } - - registerAuth(app, { store, audit }); - - const ctx: RouteContext = { store, audit, config }; - registerAuthRoutes(app, ctx); - registerUserAndClientRoutes(app, ctx); - registerReturnRoutes(app, ctx); - registerEfileRoutes(app, ctx); - registerDocumentRoutes(app, ctx); - registerPlatformRoutes(app, ctx); - - app.setErrorHandler((error: Error & { statusCode?: number }, request, reply) => { - const status = typeof error.statusCode === "number" ? error.statusCode : 500; - if (status >= 500) { - request.log?.error({ err: { name: error.name, message: scrubSensitive(error.message) } }); - return reply.code(500).send({ error: "Internal server error" }); - } - return reply.code(status).send({ error: scrubSensitive(error.message) }); - }); - - return { app, store, config, audit }; -} diff --git a/apps/api/src/services/audit-service.ts b/apps/api/src/services/audit-service.ts deleted file mode 100644 index f784ab4..0000000 --- a/apps/api/src/services/audit-service.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { - appendToChain, - verifyChain, - type AuditEventInput, - type ChainedAuditEvent, -} from "@ghimtech/audit"; -import type { Store } from "../store/types.js"; - -/** - * Appends hash-chained audit events through the store. A single service - * instance serializes appends so the chain never forks under concurrency. - */ -export class AuditService { - private queue: Promise = Promise.resolve(); - - constructor(private readonly store: Store) {} - - log( - event: Omit & { occurredAt?: string }, - ): Promise { - const task = this.queue.then(async () => { - const previous = await this.store.latestAuditEvent(); - const chained = appendToChain( - { ...event, occurredAt: event.occurredAt ?? new Date().toISOString() }, - previous, - ); - await this.store.appendAuditEvent(chained); - return chained; - }); - this.queue = task.catch(() => undefined); - return task; - } - - async verify(): Promise<{ valid: boolean; brokenAt?: number; length: number }> { - const events = await this.store.listAuditEvents({ limit: 100_000 }); - const result = verifyChain(events); - return { ...result, length: events.length }; - } -} diff --git a/apps/api/src/services/return-service.ts b/apps/api/src/services/return-service.ts deleted file mode 100644 index adc6dfe..0000000 --- a/apps/api/src/services/return-service.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * Return service: calculation, snapshotting, and lifecycle transitions with - * every platform guard in one place. - */ -import { randomUUID } from "node:crypto"; -import { - assertTransition, - hasBlockingDiagnostics, - isLocked, - type Diagnostic, - type ReturnStatus, - type Role, - type TaxReturnModel, -} from "@ghimtech/tax-domain"; -import { calculateFederal, type FederalCalculationResult } from "@ghimtech/tax-engine-federal"; -import { calculatePennsylvania, type PaCalculationResult } from "@ghimtech/tax-engine-pennsylvania"; -import { snapshotHash, type CalculationSnapshot } from "@ghimtech/forms-engine"; -import type { AuditService } from "./audit-service.js"; -import type { ReturnRecord, Store } from "../store/types.js"; - -export class ReturnServiceError extends Error { - constructor( - message: string, - public readonly statusCode = 400, - ) { - super(message); - this.name = "ReturnServiceError"; - } -} - -export interface CalculationOutcome { - snapshotHash: string; - federal: FederalCalculationResult; - pennsylvania?: PaCalculationResult; - diagnostics: Diagnostic[]; -} - -export class ReturnService { - constructor( - private readonly store: Store, - private readonly audit: AuditService, - ) {} - - async getOrThrow(id: string): Promise { - const ret = await this.store.getReturn(id); - if (!ret) throw new ReturnServiceError("Return not found", 404); - return ret; - } - - /** Update the model — only in editable states; invalidates stale signatures. */ - async updateModel( - id: string, - model: TaxReturnModel, - actor: { id: string; role: Role }, - ): Promise { - const ret = await this.getOrThrow(id); - if (isLocked(ret.status)) { - throw new ReturnServiceError( - `Return is locked in status ${ret.status} and cannot be edited`, - 409, - ); - } - const updated = await this.store.updateReturn(id, { model, latestSnapshotHash: undefined }); - // Any prior signatures are now stale by definition. - for (const signature of await this.store.listSignatures(id)) { - if (!signature.invalidatedAt) { - await this.store.updateSignature(signature.id, { - invalidatedAt: new Date().toISOString(), - invalidatedReason: "Return data changed after signature", - }); - await this.audit.log({ - action: "signature.invalidated", - actorId: actor.id, - actorRole: actor.role, - entityType: "return", - entityId: id, - }); - } - } - await this.audit.log({ - action: "return.updated", - actorId: actor.id, - actorRole: actor.role, - entityType: "return", - entityId: id, - }); - return updated; - } - - /** Run both engines, persist the snapshot + diagnostics, return the outcome. */ - async calculate(id: string, actor: { id: string; role: Role }): Promise { - const ret = await this.getOrThrow(id); - const federal = calculateFederal(ret.model); - const pennsylvania = ret.includePennsylvania ? calculatePennsylvania(ret.model) : undefined; - const snapshot: CalculationSnapshot = { model: ret.model, federal, pennsylvania }; - const hash = snapshotHash(snapshot); - const diagnostics = [...federal.diagnostics, ...(pennsylvania?.diagnostics ?? [])]; - - await this.store.saveSnapshot({ - id: randomUUID(), - returnId: id, - snapshotHash: hash, - taxYear: ret.taxYear, - ruleVersion: federal.ruleVersion, - payload: snapshot, - createdById: actor.id, - createdAt: new Date().toISOString(), - }); - await this.store.updateReturn(id, { latestSnapshotHash: hash }); - await this.audit.log({ - action: "return.calculated", - actorId: actor.id, - actorRole: actor.role, - entityType: "return", - entityId: id, - details: { snapshotHash: hash, ruleVersion: federal.ruleVersion }, - }); - return { snapshotHash: hash, federal, pennsylvania, diagnostics }; - } - - async latestSnapshot(ret: ReturnRecord): Promise { - if (!ret.latestSnapshotHash) return undefined; - const record = await this.store.getSnapshot(ret.id, ret.latestSnapshotHash); - return record?.payload as CalculationSnapshot | undefined; - } - - async currentDiagnostics(ret: ReturnRecord): Promise { - const snapshot = await this.latestSnapshot(ret); - if (!snapshot) return []; - return [...snapshot.federal.diagnostics, ...(snapshot.pennsylvania?.diagnostics ?? [])]; - } - - /** - * Transition the return. Beyond the state machine itself, enforces: - * - a calculation snapshot exists before entering review - * - reviewer separation (approver ≠ the preparer who sent it to review) - * - no blocking diagnostics on the road to filing - * - SIGNED is only reachable through the signature service - * - a valid signature exists before READY_TO_EFILE - */ - async transition( - id: string, - toStatus: ReturnStatus, - actor: { id: string; role: Role | "SYSTEM" }, - options: { note?: string; viaSignatureService?: boolean } = {}, - ): Promise { - const ret = await this.getOrThrow(id); - assertTransition(ret.status, toStatus, actor.role); - - if (toStatus === "SIGNED" && !options.viaSignatureService) { - throw new ReturnServiceError( - "Returns are signed through the signature endpoint, not a direct status change", - 400, - ); - } - - if (toStatus === "READY_FOR_REVIEWER" || toStatus === "PREPARER_REVIEWED") { - if (!ret.latestSnapshotHash) { - throw new ReturnServiceError("Run calculations before sending a return to review", 409); - } - } - - if (toStatus === "APPROVED") { - const events = await this.store.listStatusEvents(id); - const sentToReview = [...events].reverse().find((e) => e.toStatus === "READY_FOR_REVIEWER"); - if (sentToReview && sentToReview.actorId === actor.id && actor.role !== "ADMIN") { - throw new ReturnServiceError( - "Reviewer separation: the user who sent the return to review cannot approve it", - 403, - ); - } - } - - if (toStatus === "AWAITING_CLIENT_REVIEW" || toStatus === "READY_TO_EFILE") { - const diagnostics = await this.currentDiagnostics(ret); - if (hasBlockingDiagnostics(diagnostics)) { - throw new ReturnServiceError( - "Return has blocking diagnostics and cannot proceed toward filing", - 409, - ); - } - } - - if (toStatus === "READY_TO_EFILE") { - const signatures = await this.store.listSignatures(id); - const valid = signatures.find( - (s) => !s.invalidatedAt && s.snapshotHash === ret.latestSnapshotHash, - ); - if (!valid) { - throw new ReturnServiceError( - "A valid signature matching the current calculation is required before filing", - 409, - ); - } - } - - const previous = ret.status; - const updated = await this.store.updateReturn(id, { status: toStatus }); - await this.store.appendStatusEvent({ - id: randomUUID(), - returnId: id, - fromStatus: previous, - toStatus, - actorId: actor.id, - actorRole: actor.role, - note: options.note, - createdAt: new Date().toISOString(), - }); - await this.audit.log({ - action: "return.status_changed", - actorId: actor.id, - actorRole: actor.role, - entityType: "return", - entityId: id, - details: { from: previous, to: toStatus, note: options.note ?? null }, - }); - return updated; - } -} diff --git a/apps/api/src/store/memory.ts b/apps/api/src/store/memory.ts deleted file mode 100644 index 0cbe165..0000000 --- a/apps/api/src/store/memory.ts +++ /dev/null @@ -1,220 +0,0 @@ -/** - * In-memory store for development and tests. Implements the same interface - * as the Prisma store; state lives for the process lifetime. - */ -import type { ChainedAuditEvent } from "@ghimtech/audit"; -import type { ReturnStatus } from "@ghimtech/tax-domain"; -import type { - ClientRecord, - DocumentRecord, - ReturnRecord, - SessionRecord, - SignatureRecord, - SnapshotRecord, - StatusEventRecord, - Store, - SubmissionRecord, - UserRecord, -} from "./types.js"; - -export class MemoryStore implements Store { - readonly users = new Map(); - readonly sessions = new Map(); - readonly clients = new Map(); - readonly returns = new Map(); - readonly statusEvents: StatusEventRecord[] = []; - readonly snapshots = new Map(); - readonly signatures = new Map(); - readonly submissions = new Map(); - readonly documents = new Map(); - readonly auditEvents: ChainedAuditEvent[] = []; - - async createUser(user: UserRecord): Promise { - if ([...this.users.values()].some((u) => u.email === user.email)) { - throw new Error(`User with email ${user.email} already exists`); - } - this.users.set(user.id, { ...user }); - return user; - } - async getUserById(id: string): Promise { - return this.users.get(id); - } - async getUserByEmail(email: string): Promise { - return [...this.users.values()].find((u) => u.email === email); - } - async updateUser(id: string, patch: Partial): Promise { - const user = this.users.get(id); - if (!user) throw new Error("User not found"); - const updated = { ...user, ...patch }; - this.users.set(id, updated); - return updated; - } - async listUsers(): Promise { - return [...this.users.values()]; - } - - async createSession(session: SessionRecord): Promise { - this.sessions.set(session.id, { ...session }); - return session; - } - async getSessionByTokenHash(tokenHash: string): Promise { - return [...this.sessions.values()].find((s) => s.tokenHash === tokenHash); - } - async updateSession(id: string, patch: Partial): Promise { - const session = this.sessions.get(id); - if (!session) throw new Error("Session not found"); - const updated = { ...session, ...patch }; - this.sessions.set(id, updated); - return updated; - } - async revokeUserSessions(userId: string): Promise { - const now = new Date().toISOString(); - for (const session of this.sessions.values()) { - if (session.userId === userId && !session.revokedAt) session.revokedAt = now; - } - } - - async createClient(client: ClientRecord): Promise { - this.clients.set(client.id, { ...client }); - return client; - } - async getClient(id: string): Promise { - return this.clients.get(id); - } - async getClientByTinIndex(tinIndex: string): Promise { - return [...this.clients.values()].find((c) => c.tinIndex === tinIndex); - } - async getClientByUserId(userId: string): Promise { - return [...this.clients.values()].find((c) => c.userId === userId); - } - async listClients(): Promise { - return [...this.clients.values()]; - } - async updateClient(id: string, patch: Partial): Promise { - const client = this.clients.get(id); - if (!client) throw new Error("Client not found"); - const updated = { ...client, ...patch }; - this.clients.set(id, updated); - return updated; - } - - async createReturn(ret: ReturnRecord): Promise { - const duplicate = [...this.returns.values()].find( - (r) => r.clientId === ret.clientId && r.taxYear === ret.taxYear, - ); - if (duplicate) throw new Error("A return already exists for this client and tax year"); - this.returns.set(ret.id, { ...ret }); - return ret; - } - async getReturn(id: string): Promise { - return this.returns.get(id); - } - async listReturns(filter?: { - clientId?: string; - status?: ReturnStatus; - }): Promise { - return [...this.returns.values()].filter( - (r) => - (!filter?.clientId || r.clientId === filter.clientId) && - (!filter?.status || r.status === filter.status), - ); - } - async updateReturn(id: string, patch: Partial): Promise { - const ret = this.returns.get(id); - if (!ret) throw new Error("Return not found"); - const updated = { ...ret, ...patch, updatedAt: new Date().toISOString() }; - this.returns.set(id, updated); - return updated; - } - async appendStatusEvent(event: StatusEventRecord): Promise { - this.statusEvents.push({ ...event }); - } - async listStatusEvents(returnId: string): Promise { - return this.statusEvents.filter((e) => e.returnId === returnId); - } - - async saveSnapshot(snapshot: SnapshotRecord): Promise { - this.snapshots.set(`${snapshot.returnId}:${snapshot.snapshotHash}`, { ...snapshot }); - } - async getSnapshot(returnId: string, hash: string): Promise { - return this.snapshots.get(`${returnId}:${hash}`); - } - - async createSignature(signature: SignatureRecord): Promise { - this.signatures.set(signature.id, { ...signature }); - return signature; - } - async listSignatures(returnId: string): Promise { - return [...this.signatures.values()].filter((s) => s.returnId === returnId); - } - async updateSignature(id: string, patch: Partial): Promise { - const signature = this.signatures.get(id); - if (!signature) throw new Error("Signature not found"); - const updated = { ...signature, ...patch }; - this.signatures.set(id, updated); - return updated; - } - - async createSubmission(submission: SubmissionRecord): Promise { - this.submissions.set(submission.id, { ...submission }); - return submission; - } - async getSubmission(id: string): Promise { - return this.submissions.get(id); - } - async findSubmissionBySnapshot( - returnId: string, - snapshotHash: string, - ): Promise { - return [...this.submissions.values()].find( - (s) => s.returnId === returnId && s.snapshotHash === snapshotHash, - ); - } - async listSubmissions(returnId: string): Promise { - return [...this.submissions.values()].filter((s) => s.returnId === returnId); - } - async updateSubmission(id: string, patch: Partial): Promise { - const submission = this.submissions.get(id); - if (!submission) throw new Error("Submission not found"); - const updated = { ...submission, ...patch }; - this.submissions.set(id, updated); - return updated; - } - - async createDocument(doc: DocumentRecord): Promise { - this.documents.set(doc.id, { ...doc }); - return doc; - } - async getDocument(id: string): Promise { - return this.documents.get(id); - } - async listDocuments(filter: { clientId?: string; returnId?: string }): Promise { - return [...this.documents.values()].filter( - (d) => - (!filter.clientId || d.clientId === filter.clientId) && - (!filter.returnId || d.returnId === filter.returnId), - ); - } - async updateDocument(id: string, patch: Partial): Promise { - const doc = this.documents.get(id); - if (!doc) throw new Error("Document not found"); - const updated = { ...doc, ...patch }; - this.documents.set(id, updated); - return updated; - } - - async appendAuditEvent(event: ChainedAuditEvent): Promise { - this.auditEvents.push({ ...event }); - } - async latestAuditEvent(): Promise { - return this.auditEvents[this.auditEvents.length - 1]; - } - async listAuditEvents(filter?: { - entityId?: string; - limit?: number; - }): Promise { - let events = this.auditEvents; - if (filter?.entityId) events = events.filter((e) => e.entityId === filter.entityId); - return events.slice(-(filter?.limit ?? 200)); - } -} diff --git a/apps/api/src/store/prisma.ts b/apps/api/src/store/prisma.ts deleted file mode 100644 index 2b5d0f9..0000000 --- a/apps/api/src/store/prisma.ts +++ /dev/null @@ -1,673 +0,0 @@ -/** - * Prisma-backed store for staging/production. Maps the storage interface to - * the PostgreSQL schema in @ghimtech/database. - */ -import type { ChainedAuditEvent } from "@ghimtech/audit"; -import { getPrisma, type PrismaClient } from "@ghimtech/database"; -import type { ReturnStatus, TaxReturnModel } from "@ghimtech/tax-domain"; -import type { - ClientRecord, - DocumentRecord, - ReturnRecord, - SessionRecord, - SignatureRecord, - SnapshotRecord, - StatusEventRecord, - Store, - SubmissionRecord, - UserRecord, -} from "./types.js"; - -const ORG_ID = "00000000-0000-0000-0000-000000000001"; - -/* eslint-disable @typescript-eslint/no-explicit-any */ - -function mapUser(u: any): UserRecord { - return { - id: u.id, - email: u.email, - name: u.name, - role: u.role, - passwordHash: u.passwordHash, - passwordResetForced: u.passwordResetForced, - totpSecretEncrypted: u.totpSecretEncrypted ?? undefined, - mfaEnrolled: u.mfaEnrolled, - recoveryCodeHashes: u.recoveryCodeHashes, - failedLoginCount: u.failedLoginCount, - lockedUntil: u.lockedUntil?.toISOString(), - disabled: u.disabled, - }; -} - -function mapClient(c: any): ClientRecord { - const address = c.addresses?.[0]; - return { - id: c.id, - userId: c.userId ?? undefined, - assignedPreparerId: c.assignedPreparerId ?? undefined, - firstName: c.firstName, - lastName: c.lastName, - email: c.email, - phone: c.phone ?? undefined, - tags: c.tags, - tinEncrypted: c.identity?.tinEncrypted ?? "", - tinLast4: c.identity?.tinLast4 ?? "", - tinIndex: c.identity?.tinIndex ?? "", - dateOfBirth: c.identity?.dateOfBirth?.toISOString().slice(0, 10) ?? "", - address: address - ? { - line1: address.line1, - line2: address.line2 ?? undefined, - city: address.city, - state: address.state, - zip: address.zip, - } - : { line1: "", city: "", state: "", zip: "" }, - createdAt: c.createdAt.toISOString(), - }; -} - -function mapReturn(r: any): ReturnRecord { - return { - id: r.id, - clientId: r.clientId, - taxYear: r.taxYear, - status: r.status, - includePennsylvania: r.includePennsylvania, - model: r.model as TaxReturnModel, - latestSnapshotHash: r.latestSnapshotHash ?? undefined, - createdAt: r.createdAt.toISOString(), - updatedAt: r.updatedAt.toISOString(), - }; -} - -const clientInclude = { identity: true, addresses: { where: { current: true } } } as const; - -export class PrismaStore implements Store { - private readonly db: PrismaClient; - constructor(db: PrismaClient = getPrisma()) { - this.db = db; - } - - async ensureOrganization(): Promise { - await this.db.organization.upsert({ - where: { id: ORG_ID }, - create: { id: ORG_ID, name: "GhimTech" }, - update: {}, - }); - } - - async createUser(user: UserRecord): Promise { - const created = await this.db.user.create({ - data: { - id: user.id, - organizationId: ORG_ID, - email: user.email, - name: user.name, - role: user.role, - passwordHash: user.passwordHash, - passwordResetForced: user.passwordResetForced, - totpSecretEncrypted: user.totpSecretEncrypted, - mfaEnrolled: user.mfaEnrolled, - recoveryCodeHashes: user.recoveryCodeHashes, - failedLoginCount: user.failedLoginCount, - disabled: user.disabled, - }, - }); - return mapUser(created); - } - async getUserById(id: string): Promise { - const user = await this.db.user.findUnique({ where: { id } }); - return user ? mapUser(user) : undefined; - } - async getUserByEmail(email: string): Promise { - const user = await this.db.user.findUnique({ where: { email } }); - return user ? mapUser(user) : undefined; - } - async updateUser(id: string, patch: Partial): Promise { - const updated = await this.db.user.update({ - where: { id }, - data: { - ...("email" in patch ? { email: patch.email } : {}), - ...("name" in patch ? { name: patch.name } : {}), - ...("role" in patch ? { role: patch.role } : {}), - ...("passwordHash" in patch ? { passwordHash: patch.passwordHash } : {}), - ...("passwordResetForced" in patch - ? { passwordResetForced: patch.passwordResetForced } - : {}), - ...("totpSecretEncrypted" in patch - ? { totpSecretEncrypted: patch.totpSecretEncrypted } - : {}), - ...("mfaEnrolled" in patch ? { mfaEnrolled: patch.mfaEnrolled } : {}), - ...("recoveryCodeHashes" in patch ? { recoveryCodeHashes: patch.recoveryCodeHashes } : {}), - ...("failedLoginCount" in patch ? { failedLoginCount: patch.failedLoginCount } : {}), - ...("lockedUntil" in patch - ? { lockedUntil: patch.lockedUntil ? new Date(patch.lockedUntil) : null } - : {}), - ...("disabled" in patch ? { disabled: patch.disabled } : {}), - }, - }); - return mapUser(updated); - } - async listUsers(): Promise { - return (await this.db.user.findMany()).map(mapUser); - } - - async createSession(session: SessionRecord): Promise { - await this.db.session.create({ - data: { - id: session.id, - userId: session.userId, - tokenHash: session.tokenHash, - expiresAt: new Date(session.expiresAt), - mfaVerifiedAt: session.mfaVerifiedAt ? new Date(session.mfaVerifiedAt) : undefined, - ipAddress: session.ipAddress, - userAgent: session.userAgent, - }, - }); - return session; - } - async getSessionByTokenHash(tokenHash: string): Promise { - const session = await this.db.session.findUnique({ where: { tokenHash } }); - if (!session) return undefined; - return { - id: session.id, - userId: session.userId, - tokenHash: session.tokenHash, - expiresAt: session.expiresAt.toISOString(), - revokedAt: session.revokedAt?.toISOString(), - mfaVerifiedAt: session.mfaVerifiedAt?.toISOString(), - ipAddress: session.ipAddress ?? undefined, - userAgent: session.userAgent ?? undefined, - }; - } - async updateSession(id: string, patch: Partial): Promise { - const updated = await this.db.session.update({ - where: { id }, - data: { - ...("revokedAt" in patch - ? { revokedAt: patch.revokedAt ? new Date(patch.revokedAt) : null } - : {}), - ...("mfaVerifiedAt" in patch - ? { mfaVerifiedAt: patch.mfaVerifiedAt ? new Date(patch.mfaVerifiedAt) : null } - : {}), - ...("expiresAt" in patch && patch.expiresAt - ? { expiresAt: new Date(patch.expiresAt) } - : {}), - }, - }); - return { - id: updated.id, - userId: updated.userId, - tokenHash: updated.tokenHash, - expiresAt: updated.expiresAt.toISOString(), - revokedAt: updated.revokedAt?.toISOString(), - mfaVerifiedAt: updated.mfaVerifiedAt?.toISOString(), - }; - } - async revokeUserSessions(userId: string): Promise { - await this.db.session.updateMany({ - where: { userId, revokedAt: null }, - data: { revokedAt: new Date() }, - }); - } - - async createClient(client: ClientRecord): Promise { - const created = await this.db.client.create({ - data: { - id: client.id, - organizationId: ORG_ID, - userId: client.userId, - assignedPreparerId: client.assignedPreparerId, - firstName: client.firstName, - lastName: client.lastName, - email: client.email, - phone: client.phone, - tags: client.tags, - identity: { - create: { - tinEncrypted: client.tinEncrypted, - tinLast4: client.tinLast4, - tinIndex: client.tinIndex, - dateOfBirth: new Date(client.dateOfBirth), - }, - }, - addresses: { create: { ...client.address } }, - }, - include: clientInclude, - }); - return mapClient(created); - } - async getClient(id: string): Promise { - const client = await this.db.client.findUnique({ where: { id }, include: clientInclude }); - return client ? mapClient(client) : undefined; - } - async getClientByTinIndex(tinIndex: string): Promise { - const identity = await this.db.identityRecord.findUnique({ - where: { tinIndex }, - include: { client: { include: clientInclude } }, - }); - return identity ? mapClient(identity.client) : undefined; - } - async getClientByUserId(userId: string): Promise { - const client = await this.db.client.findUnique({ where: { userId }, include: clientInclude }); - return client ? mapClient(client) : undefined; - } - async listClients(): Promise { - return (await this.db.client.findMany({ include: clientInclude })).map(mapClient); - } - async updateClient(id: string, patch: Partial): Promise { - const updated = await this.db.client.update({ - where: { id }, - data: { - ...("assignedPreparerId" in patch ? { assignedPreparerId: patch.assignedPreparerId } : {}), - ...("email" in patch && patch.email ? { email: patch.email } : {}), - ...("phone" in patch ? { phone: patch.phone } : {}), - ...("tags" in patch && patch.tags ? { tags: patch.tags } : {}), - }, - include: clientInclude, - }); - return mapClient(updated); - } - - async createReturn(ret: ReturnRecord): Promise { - const created = await this.db.taxReturn.create({ - data: { - id: ret.id, - clientId: ret.clientId, - taxYear: ret.taxYear, - filingStatus: ret.model.filingStatus, - status: ret.status, - includePennsylvania: ret.includePennsylvania, - model: ret.model as object, - }, - }); - return mapReturn({ ...created, latestSnapshotHash: ret.latestSnapshotHash }); - } - async getReturn(id: string): Promise { - const ret = await this.db.taxReturn.findUnique({ where: { id } }); - if (!ret) return undefined; - const latest = await this.db.calculationSnapshotRecord.findFirst({ - where: { returnId: id }, - orderBy: { createdAt: "desc" }, - }); - return mapReturn({ ...ret, latestSnapshotHash: latest?.snapshotHash }); - } - async listReturns(filter?: { - clientId?: string; - status?: ReturnStatus; - }): Promise { - const rows = await this.db.taxReturn.findMany({ - where: { clientId: filter?.clientId, status: filter?.status }, - }); - return rows.map((r: any) => mapReturn(r)); - } - async updateReturn(id: string, patch: Partial): Promise { - const updated = await this.db.taxReturn.update({ - where: { id }, - data: { - ...("status" in patch && patch.status ? { status: patch.status } : {}), - ...("model" in patch && patch.model - ? { model: patch.model as object, filingStatus: patch.model.filingStatus } - : {}), - }, - }); - return mapReturn({ ...updated, latestSnapshotHash: patch.latestSnapshotHash }); - } - async appendStatusEvent(event: StatusEventRecord): Promise { - await this.db.returnStatusEvent.create({ - data: { - id: event.id, - returnId: event.returnId, - fromStatus: event.fromStatus, - toStatus: event.toStatus, - actorId: event.actorId, - actorRole: event.actorRole, - note: event.note, - }, - }); - } - async listStatusEvents(returnId: string): Promise { - const rows = await this.db.returnStatusEvent.findMany({ - where: { returnId }, - orderBy: { createdAt: "asc" }, - }); - return rows.map((e: any) => ({ - id: e.id, - returnId: e.returnId, - fromStatus: e.fromStatus, - toStatus: e.toStatus, - actorId: e.actorId, - actorRole: e.actorRole, - note: e.note ?? undefined, - createdAt: e.createdAt.toISOString(), - })); - } - - async saveSnapshot(snapshot: SnapshotRecord): Promise { - await this.db.calculationSnapshotRecord.upsert({ - where: { - returnId_snapshotHash: { returnId: snapshot.returnId, snapshotHash: snapshot.snapshotHash }, - }, - create: { - id: snapshot.id, - returnId: snapshot.returnId, - snapshotHash: snapshot.snapshotHash, - taxYear: snapshot.taxYear, - ruleVersion: snapshot.ruleVersion, - payload: snapshot.payload as object, - createdById: snapshot.createdById, - }, - update: {}, - }); - } - async getSnapshot(returnId: string, hash: string): Promise { - const row = await this.db.calculationSnapshotRecord.findUnique({ - where: { returnId_snapshotHash: { returnId, snapshotHash: hash } }, - }); - if (!row) return undefined; - return { - id: row.id, - returnId: row.returnId, - snapshotHash: row.snapshotHash, - taxYear: row.taxYear, - ruleVersion: row.ruleVersion, - payload: row.payload, - createdById: row.createdById, - createdAt: row.createdAt.toISOString(), - }; - } - - async createSignature(signature: SignatureRecord): Promise { - await this.db.signature.create({ - data: { - id: signature.id, - returnId: signature.returnId, - signerId: signature.signerId, - role: signature.role, - snapshotHash: signature.snapshotHash, - payloadEncrypted: signature.payloadEncrypted, - certificateHash: signature.certificateHash, - ipAddress: signature.ipAddress, - userAgent: signature.userAgent, - }, - }); - return signature; - } - async listSignatures(returnId: string): Promise { - const rows = await this.db.signature.findMany({ where: { returnId } }); - return rows.map((s: any) => ({ - id: s.id, - returnId: s.returnId, - signerId: s.signerId, - role: s.role, - snapshotHash: s.snapshotHash, - payloadEncrypted: s.payloadEncrypted, - certificateHash: s.certificateHash, - signedAt: s.signedAt.toISOString(), - ipAddress: s.ipAddress ?? undefined, - userAgent: s.userAgent ?? undefined, - invalidatedAt: s.invalidatedAt?.toISOString(), - invalidatedReason: s.invalidatedReason ?? undefined, - })); - } - async updateSignature(id: string, patch: Partial): Promise { - const updated = await this.db.signature.update({ - where: { id }, - data: { - ...("invalidatedAt" in patch - ? { invalidatedAt: patch.invalidatedAt ? new Date(patch.invalidatedAt) : null } - : {}), - ...("invalidatedReason" in patch ? { invalidatedReason: patch.invalidatedReason } : {}), - }, - }); - return { - id: updated.id, - returnId: updated.returnId, - signerId: updated.signerId, - role: updated.role as "TAXPAYER" | "SPOUSE", - snapshotHash: updated.snapshotHash, - payloadEncrypted: updated.payloadEncrypted, - certificateHash: updated.certificateHash, - signedAt: updated.signedAt.toISOString(), - invalidatedAt: updated.invalidatedAt?.toISOString(), - invalidatedReason: updated.invalidatedReason ?? undefined, - }; - } - - async createSubmission(submission: SubmissionRecord): Promise { - await this.db.filingSubmission.create({ - data: { - id: submission.id, - returnId: submission.returnId, - jurisdiction: submission.jurisdiction, - provider: submission.provider, - providerReturnId: submission.providerReturnId, - providerSubmissionId: submission.providerSubmissionId, - snapshotHash: submission.snapshotHash, - state: submission.state, - submittedById: submission.submittedById, - correctsSubmissionId: submission.correctsSubmissionId, - }, - }); - return submission; - } - private mapSubmission(s: any, ack?: any): SubmissionRecord { - return { - id: s.id, - returnId: s.returnId, - jurisdiction: s.jurisdiction, - provider: s.provider, - providerReturnId: s.providerReturnId, - providerSubmissionId: s.providerSubmissionId, - snapshotHash: s.snapshotHash, - state: s.state, - submittedById: s.submittedById, - submittedAt: s.submittedAt.toISOString(), - resolvedAt: s.resolvedAt?.toISOString(), - correctsSubmissionId: s.correctsSubmissionId ?? undefined, - acknowledgment: ack - ? { - accepted: ack.accepted, - agencyTrackingId: ack.agencyTrackingId ?? undefined, - rejections: ack.rejections as SubmissionRecord["acknowledgment"] extends infer _T - ? Array<{ code: string; message: string; location?: string }> - : never, - acknowledgedAt: ack.acknowledgedAt.toISOString(), - } - : undefined, - }; - } - async getSubmission(id: string): Promise { - const row = await this.db.filingSubmission.findUnique({ - where: { id }, - include: { acknowledgments: true }, - }); - return row ? this.mapSubmission(row, row.acknowledgments[0]) : undefined; - } - async findSubmissionBySnapshot( - returnId: string, - snapshotHash: string, - ): Promise { - const row = await this.db.filingSubmission.findFirst({ - where: { returnId, snapshotHash }, - include: { acknowledgments: true }, - }); - return row ? this.mapSubmission(row, row.acknowledgments[0]) : undefined; - } - async listSubmissions(returnId: string): Promise { - const rows = await this.db.filingSubmission.findMany({ - where: { returnId }, - include: { acknowledgments: true }, - orderBy: { submittedAt: "asc" }, - }); - return rows.map((r: any) => this.mapSubmission(r, r.acknowledgments[0])); - } - async updateSubmission(id: string, patch: Partial): Promise { - if (patch.acknowledgment) { - await this.db.filingAcknowledgment.upsert({ - where: { submissionId: id }, - create: { - submissionId: id, - accepted: patch.acknowledgment.accepted, - agencyTrackingId: patch.acknowledgment.agencyTrackingId, - rejections: patch.acknowledgment.rejections as object[], - acknowledgedAt: new Date(patch.acknowledgment.acknowledgedAt), - }, - update: {}, - }); - } - const updated = await this.db.filingSubmission.update({ - where: { id }, - data: { - ...("state" in patch && patch.state ? { state: patch.state } : {}), - ...("resolvedAt" in patch - ? { resolvedAt: patch.resolvedAt ? new Date(patch.resolvedAt) : null } - : {}), - }, - include: { acknowledgments: true }, - }); - return this.mapSubmission(updated, updated.acknowledgments[0]); - } - - async createDocument(doc: DocumentRecord): Promise { - await this.db.document.create({ - data: { - id: doc.id, - clientId: doc.clientId, - returnId: doc.returnId, - uploadedById: doc.uploadedById, - category: doc.category, - status: doc.status === "PENDING_VERIFICATION" ? "PENDING_VERIFICATION" : doc.status, - filename: doc.filename, - mimeType: doc.mimeType, - sizeBytes: doc.sizeBytes, - storageKey: `inline/${doc.id}`, - sha256: doc.sha256, - }, - }); - return doc; - } - async getDocument(id: string): Promise { - const row = await this.db.document.findUnique({ - where: { id }, - include: { ocrResults: true, verifications: true }, - }); - if (!row) return undefined; - return { - id: row.id, - clientId: row.clientId, - returnId: row.returnId ?? undefined, - uploadedById: row.uploadedById, - category: row.category, - status: row.status as DocumentRecord["status"], - filename: row.filename, - mimeType: row.mimeType, - sizeBytes: row.sizeBytes, - sha256: row.sha256, - ocr: row.ocrResults[0]?.fields, - verification: row.verifications[0]?.fields, - createdAt: row.createdAt.toISOString(), - }; - } - async listDocuments(filter: { clientId?: string; returnId?: string }): Promise { - const rows = await this.db.document.findMany({ - where: { clientId: filter.clientId, returnId: filter.returnId }, - }); - return rows.map((row: any) => ({ - id: row.id, - clientId: row.clientId, - returnId: row.returnId ?? undefined, - uploadedById: row.uploadedById, - category: row.category, - status: row.status, - filename: row.filename, - mimeType: row.mimeType, - sizeBytes: row.sizeBytes, - sha256: row.sha256, - createdAt: row.createdAt.toISOString(), - })); - } - async updateDocument(id: string, patch: Partial): Promise { - if (patch.ocr) { - const existing = await this.db.document.findUnique({ where: { id } }); - if (existing) { - await this.db.ocrResultRecord.create({ - data: { - documentId: id, - engine: "configured", - category: patch.category ?? existing.category, - categoryConfidence: 1, - fields: patch.ocr as object, - }, - }); - } - } - const updated = await this.db.document.update({ - where: { id }, - data: { - ...("status" in patch && patch.status ? { status: patch.status } : {}), - ...("category" in patch && patch.category ? { category: patch.category } : {}), - }, - }); - return (await this.getDocument(updated.id))!; - } - - async appendAuditEvent(event: ChainedAuditEvent): Promise { - await this.db.auditEvent.create({ - data: { - action: event.action, - actorId: event.actorId, - actorRole: event.actorRole, - entityType: event.entityType, - entityId: event.entityId, - details: event.details as object | undefined, - ipAddress: event.ipAddress, - userAgent: event.userAgent, - occurredAt: new Date(event.occurredAt), - hash: event.hash, - previousHash: event.previousHash, - }, - }); - } - async latestAuditEvent(): Promise { - const row = await this.db.auditEvent.findFirst({ orderBy: { sequence: "desc" } }); - if (!row) return undefined; - return { - action: row.action as ChainedAuditEvent["action"], - actorId: row.actorId, - actorRole: row.actorRole, - entityType: row.entityType ?? undefined, - entityId: row.entityId ?? undefined, - details: (row.details as ChainedAuditEvent["details"]) ?? undefined, - ipAddress: row.ipAddress ?? undefined, - userAgent: row.userAgent ?? undefined, - occurredAt: row.occurredAt.toISOString(), - hash: row.hash, - previousHash: row.previousHash, - }; - } - async listAuditEvents(filter?: { - entityId?: string; - limit?: number; - }): Promise { - const rows = await this.db.auditEvent.findMany({ - where: filter?.entityId ? { entityId: filter.entityId } : undefined, - orderBy: { sequence: "asc" }, - take: filter?.limit ?? 200, - }); - return rows.map((row: any) => ({ - action: row.action, - actorId: row.actorId, - actorRole: row.actorRole, - entityType: row.entityType ?? undefined, - entityId: row.entityId ?? undefined, - details: row.details ?? undefined, - ipAddress: row.ipAddress ?? undefined, - userAgent: row.userAgent ?? undefined, - occurredAt: row.occurredAt.toISOString(), - hash: row.hash, - previousHash: row.previousHash, - })); - } -} diff --git a/apps/api/src/store/types.ts b/apps/api/src/store/types.ts deleted file mode 100644 index 916033e..0000000 --- a/apps/api/src/store/types.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * Storage interface for the API. Two implementations: - * - MemoryStore: development and automated tests (no infrastructure needed) - * - PrismaStore: staging/production against PostgreSQL - * - * Handlers and services depend only on this interface, so every workflow and - * permission test runs against the exact code paths production uses. - */ -import type { ChainedAuditEvent } from "@ghimtech/audit"; -import type { ReturnStatus, Role, TaxReturnModel } from "@ghimtech/tax-domain"; - -export interface UserRecord { - id: string; - email: string; - name: string; - role: Role; - passwordHash: string; - passwordResetForced: boolean; - totpSecretEncrypted?: string; - mfaEnrolled: boolean; - recoveryCodeHashes: string[]; - failedLoginCount: number; - lockedUntil?: string; - disabled: boolean; -} - -export interface SessionRecord { - id: string; - userId: string; - tokenHash: string; - expiresAt: string; - revokedAt?: string; - mfaVerifiedAt?: string; - ipAddress?: string; - userAgent?: string; -} - -export interface ClientRecord { - id: string; - userId?: string; - assignedPreparerId?: string; - firstName: string; - lastName: string; - email: string; - phone?: string; - tags: string[]; - tinEncrypted: string; - tinLast4: string; - tinIndex: string; - dateOfBirth: string; - address: { line1: string; line2?: string; city: string; state: string; zip: string }; - createdAt: string; -} - -export interface ReturnRecord { - id: string; - clientId: string; - taxYear: number; - status: ReturnStatus; - includePennsylvania: boolean; - model: TaxReturnModel; - /** Latest calculation snapshot hash, when calculated. */ - latestSnapshotHash?: string; - createdAt: string; - updatedAt: string; -} - -export interface StatusEventRecord { - id: string; - returnId: string; - fromStatus: ReturnStatus; - toStatus: ReturnStatus; - actorId: string; - actorRole: string; - note?: string; - createdAt: string; -} - -export interface SnapshotRecord { - id: string; - returnId: string; - snapshotHash: string; - taxYear: number; - ruleVersion: string; - payload: unknown; - createdById: string; - createdAt: string; -} - -export interface SignatureRecord { - id: string; - returnId: string; - signerId: string; - role: "TAXPAYER" | "SPOUSE"; - snapshotHash: string; - payloadEncrypted: string; - certificateHash: string; - signedAt: string; - ipAddress?: string; - userAgent?: string; - invalidatedAt?: string; - invalidatedReason?: string; -} - -export interface SubmissionRecord { - id: string; - returnId: string; - jurisdiction: "FEDERAL" | "PENNSYLVANIA"; - provider: string; - providerReturnId: string; - providerSubmissionId: string; - snapshotHash: string; - state: "QUEUED" | "TRANSMITTING" | "TRANSMITTED" | "ACCEPTED" | "REJECTED"; - submittedById: string; - submittedAt: string; - resolvedAt?: string; - correctsSubmissionId?: string; - acknowledgment?: { - accepted: boolean; - agencyTrackingId?: string; - rejections: Array<{ code: string; message: string; location?: string }>; - acknowledgedAt: string; - }; -} - -export interface DocumentRecord { - id: string; - clientId: string; - returnId?: string; - uploadedById: string; - category: string; - status: "UPLOADED" | "SCANNING" | "QUARANTINED" | "PENDING_VERIFICATION" | "VERIFIED" | "DELETED"; - filename: string; - mimeType: string; - sizeBytes: number; - sha256: string; - /** Encrypted content (dev store keeps it inline; production uses object storage). */ - contentEncrypted?: string; - ocr?: unknown; - verification?: unknown; - createdAt: string; -} - -export interface Store { - // Users - createUser(user: UserRecord): Promise; - getUserById(id: string): Promise; - getUserByEmail(email: string): Promise; - updateUser(id: string, patch: Partial): Promise; - listUsers(): Promise; - - // Sessions - createSession(session: SessionRecord): Promise; - getSessionByTokenHash(tokenHash: string): Promise; - updateSession(id: string, patch: Partial): Promise; - revokeUserSessions(userId: string): Promise; - - // Clients - createClient(client: ClientRecord): Promise; - getClient(id: string): Promise; - getClientByTinIndex(tinIndex: string): Promise; - getClientByUserId(userId: string): Promise; - listClients(): Promise; - updateClient(id: string, patch: Partial): Promise; - - // Returns - createReturn(ret: ReturnRecord): Promise; - getReturn(id: string): Promise; - listReturns(filter?: { clientId?: string; status?: ReturnStatus }): Promise; - updateReturn(id: string, patch: Partial): Promise; - appendStatusEvent(event: StatusEventRecord): Promise; - listStatusEvents(returnId: string): Promise; - - // Snapshots - saveSnapshot(snapshot: SnapshotRecord): Promise; - getSnapshot(returnId: string, hash: string): Promise; - - // Signatures - createSignature(signature: SignatureRecord): Promise; - listSignatures(returnId: string): Promise; - updateSignature(id: string, patch: Partial): Promise; - - // Submissions - createSubmission(submission: SubmissionRecord): Promise; - getSubmission(id: string): Promise; - findSubmissionBySnapshot( - returnId: string, - snapshotHash: string, - ): Promise; - listSubmissions(returnId: string): Promise; - updateSubmission(id: string, patch: Partial): Promise; - - // Documents - createDocument(doc: DocumentRecord): Promise; - getDocument(id: string): Promise; - listDocuments(filter: { clientId?: string; returnId?: string }): Promise; - updateDocument(id: string, patch: Partial): Promise; - - // Audit - appendAuditEvent(event: ChainedAuditEvent): Promise; - latestAuditEvent(): Promise; - listAuditEvents(filter?: { entityId?: string; limit?: number }): Promise; -} diff --git a/apps/api/src/test-helpers.ts b/apps/api/src/test-helpers.ts deleted file mode 100644 index 8b1a605..0000000 --- a/apps/api/src/test-helpers.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Test helpers: an API instance on the in-memory store with deterministic - * keys, plus a login helper that walks the full password + TOTP MFA flow. - */ -import type { FastifyInstance } from "fastify"; -import { hashPassword, totpCode } from "@ghimtech/security"; -import type { Role } from "@ghimtech/tax-domain"; -import { randomUUID } from "node:crypto"; -import { loadConfig } from "./env.js"; -import { buildServer } from "./server.js"; -import { MemoryStore } from "./store/memory.js"; - -export const TEST_PASSWORD = "TestPassw0rdLong!"; - -/** TOTP secrets captured at first enrollment, for later logins in the same test file. */ -const totpSecrets = new Map(); - -export async function buildTestServer() { - const config = loadConfig({ - GHIMTECH_ENV: "test", - GHIMTECH_MASTER_KEY: "a".repeat(64), - GHIMTECH_INDEX_KEY: "b".repeat(64), - GHIMTECH_STORE: "memory", - GHIMTECH_EFILE_PROVIDER: "mock", - } as NodeJS.ProcessEnv); - const store = new MemoryStore(); - const { app, audit } = await buildServer({ config, store }); - return { app, store, config, audit }; -} - -export async function createUser( - store: MemoryStore, - role: Role, - email = `${role.toLowerCase()}-${randomUUID().slice(0, 8)}@test.ghimtech.example`, -) { - return store.createUser({ - id: randomUUID(), - email, - name: `Test ${role}`, - role, - passwordHash: await hashPassword(TEST_PASSWORD), - passwordResetForced: false, - mfaEnrolled: false, - recoveryCodeHashes: [], - failedLoginCount: 0, - disabled: false, - }); -} - -/** Full login: password → (enrollment) → TOTP verify → bearer token. */ -export async function login(app: FastifyInstance, email: string): Promise { - const loginRes = await app.inject({ - method: "POST", - url: "/auth/login", - payload: { email, password: TEST_PASSWORD }, - }); - if (loginRes.statusCode !== 200) { - throw new Error(`login failed: ${loginRes.statusCode} ${loginRes.body}`); - } - const loginBody = loginRes.json() as { - mfaToken: string; - mfaEnrollment?: { secret: string }; - }; - let secret = loginBody.mfaEnrollment?.secret; - if (secret) { - totpSecrets.set(email, secret); - } else { - secret = totpSecrets.get(email); - } - if (!secret) throw new Error("no TOTP secret available for this user"); - const verifyRes = await app.inject({ - method: "POST", - url: "/auth/mfa/verify", - payload: { mfaToken: loginBody.mfaToken, code: totpCode(secret, Date.now()) }, - }); - if (verifyRes.statusCode !== 200) { - throw new Error(`mfa failed: ${verifyRes.statusCode} ${verifyRes.body}`); - } - return (verifyRes.json() as { token: string }).token; -} - -export function authed(token: string) { - return { authorization: `Bearer ${token}` }; -} diff --git a/apps/api/src/workflow.test.ts b/apps/api/src/workflow.test.ts deleted file mode 100644 index 33928b2..0000000 --- a/apps/api/src/workflow.test.ts +++ /dev/null @@ -1,501 +0,0 @@ -/** - * End-to-end workflow tests over the real API surface (in-memory store, - * mock e-file provider): the full happy path from client creation to an - * accepted, archived filing, plus the rejection/correction/resubmission - * cycle, duplicate-submission protection, and change-after-signature rules. - */ -import { beforeAll, describe, expect, it } from "vitest"; -import type { FastifyInstance } from "fastify"; -import { singleW2Return } from "@ghimtech/testing"; -import type { TaxReturnModel } from "@ghimtech/tax-domain"; -import type { MemoryStore } from "./store/memory.js"; -import { authed, buildTestServer, createUser, login } from "./test-helpers.js"; - -let app: FastifyInstance; -let store: MemoryStore; -let adminToken: string; -let preparerToken: string; -let reviewerToken: string; - -const CLIENT_PAYLOAD = { - name: { firstName: "Avery", lastName: "Testcase" }, - email: "avery.client@test.ghimtech.example", - address: { line1: "100 Synthetic St", city: "Harrisburg", state: "PA", zip: "17101" }, - dateOfBirth: "1985-06-15", - tin: "123-45-6789", -}; - -async function post(url: string, token: string, payload?: unknown) { - return app.inject({ method: "POST", url, headers: authed(token), payload }); -} -async function get(url: string, token: string) { - return app.inject({ method: "GET", url, headers: authed(token) }); -} - -function modelFor(returnId: string, overrides: Partial = {}): TaxReturnModel { - const model = singleW2Return(overrides); - model.returnId = returnId; - return model; -} - -beforeAll(async () => { - const built = await buildTestServer(); - app = built.app; - store = built.store as MemoryStore; - adminToken = await login(app, (await createUser(store, "ADMIN")).email); - preparerToken = await login(app, (await createUser(store, "PREPARER")).email); - reviewerToken = await login(app, (await createUser(store, "REVIEWER")).email); -}); - -describe("full filing lifecycle (happy path)", () => { - let clientId: string; - let returnId: string; - let clientToken: string; - let snapshotHash: string; - - it("preparer creates a client (duplicate TIN blocked)", async () => { - const created = await post("/clients", preparerToken, CLIENT_PAYLOAD); - expect(created.statusCode).toBe(201); - clientId = created.json().id; - expect(created.json().tinMasked).toBe("***-**-6789"); - - const dup = await post("/clients", preparerToken, { - ...CLIENT_PAYLOAD, - email: "other@test.ghimtech.example", - }); - expect(dup.statusCode).toBe(409); - }); - - it("creates a 2025 return and fills the model from intake", async () => { - const created = await post("/returns", preparerToken, { - clientId, - taxYear: 2025, - filingStatus: "SINGLE", - includePennsylvania: true, - }); - expect(created.statusCode).toBe(201); - returnId = created.json().id; - expect(created.json().status).toBe("DRAFT"); - - const updated = await app.inject({ - method: "PUT", - url: `/returns/${returnId}/model`, - headers: authed(preparerToken), - payload: modelFor(returnId), - }); - expect(updated.statusCode).toBe(200); - }); - - it("calculates with traceable results and no blocking diagnostics", async () => { - const calc = await post(`/returns/${returnId}/calculate`, preparerToken); - expect(calc.statusCode).toBe(200); - const body = calc.json(); - snapshotHash = body.snapshotHash; - expect(body.federal.refund).toBe(92_500); - expect(body.pennsylvania.taxLiability).toBe(184_200); - expect( - body.diagnostics.filter((d: { severity: string }) => d.severity === "ERROR"), - ).toHaveLength(0); - expect(body.federal.trace.entries.length).toBeGreaterThan(5); - }); - - it("walks preparer review and enforces reviewer separation", async () => { - await post(`/returns/${returnId}/transition`, preparerToken, { - toStatus: "READY_FOR_PREPARER_REVIEW", - }); - await post(`/returns/${returnId}/transition`, preparerToken, { toStatus: "PREPARER_REVIEWED" }); - const toReviewer = await post(`/returns/${returnId}/transition`, preparerToken, { - toStatus: "READY_FOR_REVIEWER", - }); - expect(toReviewer.statusCode).toBe(200); - - // The preparer cannot approve at all (role), and the sender could not - // approve even with the reviewer role (identity separation). - const preparerApprove = await post(`/returns/${returnId}/transition`, preparerToken, { - toStatus: "APPROVED", - }); - expect(preparerApprove.statusCode).toBe(403); - - const approve = await post(`/returns/${returnId}/transition`, reviewerToken, { - toStatus: "APPROVED", - }); - expect(approve.statusCode).toBe(200); - expect(approve.json().status).toBe("APPROVED"); - }); - - it("client reviews the package and signs", async () => { - const portal = await post(`/clients/${clientId}/portal-account`, preparerToken, { - temporaryPassword: "ClientTempPass123", - }); - expect(portal.statusCode).toBe(201); - // Client user shares TEST_PASSWORD? No — portal uses its own temp password. - // Reset it in the store to the shared test password for the login helper. - const clientUser = await store.getUserById(portal.json().userId); - const { hashPassword } = await import("@ghimtech/security"); - await store.updateUser(clientUser!.id, { - passwordHash: await hashPassword((await import("./test-helpers.js")).TEST_PASSWORD), - }); - clientToken = await login(app, clientUser!.email); - - await post(`/returns/${returnId}/transition`, preparerToken, { - toStatus: "AWAITING_CLIENT_REVIEW", - }); - - // Client can see their package but not other clients' data. - const pkg = await get(`/returns/${returnId}/package`, clientToken); - expect(pkg.statusCode).toBe(200); - expect(pkg.json().watermark).toBe("CLIENT_COPY"); - const others = await get("/clients", clientToken); - expect(others.statusCode).toBe(403); - - await post(`/returns/${returnId}/transition`, clientToken, { toStatus: "AWAITING_SIGNATURE" }); - - // A stale snapshot hash cannot be signed. - const staleSign = await post(`/returns/${returnId}/signatures`, clientToken, { - signatureText: "Avery Testcase", - consentAcknowledged: true, - reviewedSnapshotHash: "f".repeat(64), - }); - expect(staleSign.statusCode).toBe(409); - - const sign = await post(`/returns/${returnId}/signatures`, clientToken, { - signatureText: "Avery Testcase", - consentAcknowledged: true, - reviewedSnapshotHash: snapshotHash, - }); - expect(sign.statusCode).toBe(201); - expect(sign.json().status).toBe("SIGNED"); - expect(sign.json().certificateHash).toHaveLength(64); - }); - - it("locks the signed return against edits", async () => { - const edit = await app.inject({ - method: "PUT", - url: `/returns/${returnId}/model`, - headers: authed(preparerToken), - payload: modelFor(returnId), - }); - expect(edit.statusCode).toBe(409); - }); - - it("e-files through the mock provider and reaches ACCEPTED", async () => { - await post(`/returns/${returnId}/transition`, preparerToken, { toStatus: "READY_TO_EFILE" }); - const filed = await post(`/returns/${returnId}/efile`, preparerToken); - expect(filed.statusCode).toBe(202); - - const ret = await get(`/returns/${returnId}`, preparerToken); - expect(ret.json().status).toBe("ACKNOWLEDGMENT_PENDING"); - - // Duplicate submission of the same snapshot is impossible. - const statusNow = await get(`/returns/${returnId}`, preparerToken); - expect(statusNow.json().status).not.toBe("READY_TO_EFILE"); - - const poll = await post(`/returns/${returnId}/efile/poll`, preparerToken); - expect(poll.statusCode).toBe(200); - expect(poll.json().status).toBe("ACCEPTED"); - const submissions = poll.json().submissions; - expect(submissions).toHaveLength(1); - expect(submissions[0].acknowledgment.accepted).toBe(true); - expect(submissions[0].acknowledgment.agencyTrackingId).toBeTruthy(); - }); - - it("archives the accepted return and keeps the audit chain intact", async () => { - const archived = await post(`/returns/${returnId}/transition`, preparerToken, { - toStatus: "ARCHIVED", - }); - expect(archived.json().status).toBe("ARCHIVED"); - const verify = await get("/audit/verify", adminToken); - expect(verify.json().valid).toBe(true); - expect(verify.json().length).toBeGreaterThan(10); - const history = await get(`/returns/${returnId}/history`, preparerToken); - const transitions = history.json() as Array<{ fromStatus: string; toStatus: string }>; - expect(transitions[0]).toMatchObject({ fromStatus: "DRAFT" }); - expect(transitions.at(-1)).toMatchObject({ toStatus: "ARCHIVED" }); - }); - - it("dashboard reflects the accepted filing", async () => { - const dash = await get("/dashboard", preparerToken); - expect(dash.statusCode).toBe(200); - expect(dash.json().totals.accepted).toBeGreaterThanOrEqual(1); - }); -}); - -describe("rejection, correction, and resubmission", () => { - let clientId: string; - let returnId: string; - - async function driveToReadyToEfile(model: TaxReturnModel, signToken: string) { - await app.inject({ - method: "PUT", - url: `/returns/${returnId}/model`, - headers: authed(preparerToken), - payload: model, - }); - const calc = await post(`/returns/${returnId}/calculate`, preparerToken); - const hash = calc.json().snapshotHash; - await post(`/returns/${returnId}/transition`, preparerToken, { - toStatus: "READY_FOR_PREPARER_REVIEW", - }); - await post(`/returns/${returnId}/transition`, preparerToken, { toStatus: "PREPARER_REVIEWED" }); - await post(`/returns/${returnId}/transition`, preparerToken, { - toStatus: "READY_FOR_REVIEWER", - }); - await post(`/returns/${returnId}/transition`, reviewerToken, { toStatus: "APPROVED" }); - await post(`/returns/${returnId}/transition`, preparerToken, { - toStatus: "AWAITING_CLIENT_REVIEW", - }); - await post(`/returns/${returnId}/transition`, signToken, { toStatus: "AWAITING_SIGNATURE" }); - const sign = await post(`/returns/${returnId}/signatures`, signToken, { - signatureText: "Jordan Rejectcase", - consentAcknowledged: true, - reviewedSnapshotHash: hash, - }); - expect(sign.statusCode).toBe(201); - const ready = await post(`/returns/${returnId}/transition`, preparerToken, { - toStatus: "READY_TO_EFILE", - }); - expect(ready.statusCode).toBe(200); - return hash; - } - - it("sets up a client whose SSN triggers the scripted provider rejection", async () => { - const created = await post("/clients", preparerToken, { - ...CLIENT_PAYLOAD, - name: { firstName: "Jordan", lastName: "Rejectcase" }, - email: "jordan.reject@test.ghimtech.example", - tin: "234-56-9999", // last4 9999 → mock provider rejects with R0000-500-01 - }); - expect(created.statusCode).toBe(201); - clientId = created.json().id; - const ret = await post("/returns", preparerToken, { - clientId, - taxYear: 2025, - filingStatus: "SINGLE", - includePennsylvania: false, - }); - returnId = ret.json().id; - }); - - it("rejected by the agency → CORRECTION_REQUIRED with explanations", async () => { - // Admin acts for the client here; the identity-side flows were covered above. - const model = modelFor(returnId); - model.taxpayer = { ...model.taxpayer, ssnLast4: "9999" }; - await driveToReadyToEfile(model, adminToken); - - const filed = await post(`/returns/${returnId}/efile`, adminToken); - expect(filed.statusCode).toBe(202); - const poll = await post(`/returns/${returnId}/efile/poll`, preparerToken); - expect(poll.json().status).toBe("CORRECTION_REQUIRED"); - const rejection = poll.json().submissions[0].acknowledgment.rejections[0]; - expect(rejection.code).toBe("R0000-500-01"); - expect(rejection.explanation).toMatch(/Social Security Administration/); - expect(rejection.correctiveAction).toBeTruthy(); - }); - - it("correction invalidates the old signature, re-signs, resubmits, and is accepted", async () => { - await post(`/returns/${returnId}/transition`, preparerToken, { - toStatus: "READY_FOR_PREPARER_REVIEW", - }); - // Back through the pipeline with corrected data (model edit → signature invalidated). - const backToDraftish = await post(`/returns/${returnId}/transition`, preparerToken, { - toStatus: "INCOMPLETE", - }); - expect(backToDraftish.statusCode).toBe(200); - - const corrected = modelFor(returnId); - corrected.taxpayer = { ...corrected.taxpayer, ssnLast4: "6789" }; - await app.inject({ - method: "PUT", - url: `/returns/${returnId}/model`, - headers: authed(preparerToken), - payload: corrected, - }); - const signatures = await get(`/returns/${returnId}/signatures`, preparerToken); - expect(signatures.json().every((s: { invalidatedAt?: string }) => s.invalidatedAt)).toBe(true); - - const calc = await post(`/returns/${returnId}/calculate`, preparerToken); - const hash = calc.json().snapshotHash; - await post(`/returns/${returnId}/transition`, preparerToken, { - toStatus: "READY_FOR_PREPARER_REVIEW", - }); - await post(`/returns/${returnId}/transition`, preparerToken, { toStatus: "PREPARER_REVIEWED" }); - await post(`/returns/${returnId}/transition`, preparerToken, { - toStatus: "READY_FOR_REVIEWER", - }); - await post(`/returns/${returnId}/transition`, reviewerToken, { toStatus: "APPROVED" }); - await post(`/returns/${returnId}/transition`, preparerToken, { - toStatus: "AWAITING_CLIENT_REVIEW", - }); - await post(`/returns/${returnId}/transition`, adminToken, { toStatus: "AWAITING_SIGNATURE" }); - await post(`/returns/${returnId}/signatures`, adminToken, { - signatureText: "Jordan Rejectcase", - consentAcknowledged: true, - reviewedSnapshotHash: hash, - }); - await post(`/returns/${returnId}/transition`, preparerToken, { toStatus: "READY_TO_EFILE" }); - - const refiled = await post(`/returns/${returnId}/efile`, preparerToken); - expect(refiled.statusCode).toBe(202); - const poll = await post(`/returns/${returnId}/efile/poll`, preparerToken); - expect(poll.json().status).toBe("ACCEPTED"); - expect(poll.json().submissions).toHaveLength(2); - }); -}); - -describe("filing guards", () => { - it("blocks e-filing a return with blocking diagnostics", async () => { - const created = await post("/clients", preparerToken, { - ...CLIENT_PAYLOAD, - email: "blocked@test.ghimtech.example", - tin: "345-67-8912", - }); - const clientId = created.json().id; - const ret = await post("/returns", preparerToken, { - clientId, - taxYear: 2025, - filingStatus: "SINGLE", - includePennsylvania: false, - }); - const returnId = ret.json().id; - const model = modelFor(returnId, { - selfEmployment: [ - { - id: "se-1", - businessName: "Complex Co", - description: "x", - grossReceipts: 10_000_00, - totalExpenses: 0, - requiresComplexSchedule: true, // → blocking diagnostic - belongsToSpouse: false, - }, - ], - }); - await app.inject({ - method: "PUT", - url: `/returns/${returnId}/model`, - headers: authed(preparerToken), - payload: model, - }); - const calc = await post(`/returns/${returnId}/calculate`, preparerToken); - expect(calc.json().diagnostics.some((d: { severity: string }) => d.severity === "ERROR")).toBe( - true, - ); - await post(`/returns/${returnId}/transition`, preparerToken, { - toStatus: "READY_FOR_PREPARER_REVIEW", - }); - await post(`/returns/${returnId}/transition`, preparerToken, { toStatus: "PREPARER_REVIEWED" }); - await post(`/returns/${returnId}/transition`, preparerToken, { - toStatus: "READY_FOR_REVIEWER", - }); - await post(`/returns/${returnId}/transition`, reviewerToken, { toStatus: "APPROVED" }); - // Blocked on the way toward the client/filing. - const toClient = await post(`/returns/${returnId}/transition`, preparerToken, { - toStatus: "AWAITING_CLIENT_REVIEW", - }); - expect(toClient.statusCode).toBe(409); - expect(toClient.json().error).toMatch(/blocking diagnostics/); - }); - - it("cannot reach READY_TO_EFILE without a valid signature", async () => { - const created = await post("/clients", preparerToken, { - ...CLIENT_PAYLOAD, - email: "nosig@test.ghimtech.example", - tin: "456-78-9123", - }); - const ret = await post("/returns", preparerToken, { - clientId: created.json().id, - taxYear: 2025, - filingStatus: "SINGLE", - includePennsylvania: false, - }); - const returnId = ret.json().id; - await app.inject({ - method: "PUT", - url: `/returns/${returnId}/model`, - headers: authed(preparerToken), - payload: modelFor(returnId), - }); - await post(`/returns/${returnId}/calculate`, preparerToken); - await post(`/returns/${returnId}/transition`, preparerToken, { - toStatus: "READY_FOR_PREPARER_REVIEW", - }); - await post(`/returns/${returnId}/transition`, preparerToken, { toStatus: "PREPARER_REVIEWED" }); - await post(`/returns/${returnId}/transition`, preparerToken, { - toStatus: "READY_FOR_REVIEWER", - }); - await post(`/returns/${returnId}/transition`, reviewerToken, { toStatus: "APPROVED" }); - await post(`/returns/${returnId}/transition`, preparerToken, { - toStatus: "AWAITING_CLIENT_REVIEW", - }); - await post(`/returns/${returnId}/transition`, preparerToken, { - toStatus: "AWAITING_SIGNATURE", - }); - // Skip signing; SIGNED cannot be set directly… - const directSigned = await post(`/returns/${returnId}/transition`, adminToken, { - toStatus: "SIGNED", - }); - expect(directSigned.statusCode).toBe(400); - }); - - it("unsupported tax years are rejected at calculation", async () => { - const created = await post("/clients", preparerToken, { - ...CLIENT_PAYLOAD, - email: "oldyear@test.ghimtech.example", - tin: "567-89-1234", - }); - const ret = await post("/returns", preparerToken, { - clientId: created.json().id, - taxYear: 2023, - filingStatus: "SINGLE", - includePennsylvania: false, - }); - const calc = await post(`/returns/${ret.json().id}/calculate`, preparerToken); - expect(calc.statusCode).toBe(422); - expect(calc.json().error).toMatch(/not supported/); - }); -}); - -describe("documents", () => { - it("uploads, OCRs, and verifies a synthetic W-2; quarantines EICAR", async () => { - const created = await post("/clients", preparerToken, { - ...CLIENT_PAYLOAD, - email: "docs@test.ghimtech.example", - tin: "678-91-2345", - }); - const clientId = created.json().id; - - const w2Text = - "Form W-2 Wage and Tax Statement\nEMPLOYER: Synthetic Employer LLC\nBox 1: 60,000.00\nBox 2: 6,000.00"; - const upload = await post("/documents", preparerToken, { - clientId, - filename: "w2-2025.txt", - contentBase64: Buffer.from(w2Text).toString("base64"), - }); - expect(upload.statusCode).toBe(201); - expect(upload.json().category).toBe("W2"); - expect(upload.json().status).toBe("PENDING_VERIFICATION"); - const docId = upload.json().id; - - const verify = await post(`/documents/${docId}/verify`, preparerToken, { - categoryConfirmed: true, - fields: [{ key: "wages", verifiedValue: "60,000.00" }], - }); - expect(verify.json().status).toBe("VERIFIED"); - - const eicar = "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"; - const flagged = await post("/documents", preparerToken, { - clientId, - filename: "invoice.txt", - contentBase64: Buffer.from(eicar, "latin1").toString("base64"), - }); - expect(flagged.statusCode).toBe(422); - expect(flagged.json().error).toMatch(/malware/); - - const spoofed = await post("/documents", preparerToken, { - clientId, - filename: "statement.pdf", - contentBase64: Buffer.from("MZ not a pdf").toString("base64"), - }); - expect(spoofed.statusCode).toBe(422); - }); -}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json deleted file mode 100644 index 7f83951..0000000 --- a/apps/api/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../packages/config/tsconfig.package.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] -} diff --git a/apps/api/vitest.config.mts b/apps/api/vitest.config.mts deleted file mode 100644 index fd161ed..0000000 --- a/apps/api/vitest.config.mts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../../packages/config/vitest.base.mjs"; diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index a68fc12..2a6eac2 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,11 +1,7 @@ import type { NextConfig } from "next"; - -const nextConfig: NextConfig = { - transpilePackages: ["@ghimtech/ui", "@ghimtech/tax-domain"], - // Standalone output is for the self-hosted Docker image; Vercel manages - // its own build output. - output: process.env.VERCEL ? undefined : "standalone", +const config: NextConfig = { poweredByHeader: false, + output: process.env.GHIMTECH_STANDALONE === "1" ? "standalone" : undefined, headers: async () => [ { source: "/:path*", @@ -13,14 +9,17 @@ const nextConfig: NextConfig = { { key: "X-Frame-Options", value: "DENY" }, { key: "X-Content-Type-Options", value: "nosniff" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, + { key: "Strict-Transport-Security", value: "max-age=31536000" }, { key: "Content-Security-Policy", value: - "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' http://localhost:4000 https://*.ghimtech.org https://*.up.railway.app; frame-ancestors 'none'", + "default-src 'self'; script-src 'self' 'unsafe-inline'" + + (process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : "") + + "; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'", }, ], }, ], }; - -export default nextConfig; +export default config; diff --git a/apps/web/package.json b/apps/web/package.json index 7d6b7e4..e868771 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,21 +1,16 @@ { "name": "@ghimtech/web", - "version": "0.1.0", + "version": "1.0.0", "private": true, - "description": "GhimTech Tax web application", "author": "Durga Ghimeray", "scripts": { "dev": "next dev -p 3000", "build": "next build", "start": "next start -p 3000", - "lint": "next lint", - "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "echo 'web workflows covered by api e2e - visual tests planned in docs/testing.md'" + "typecheck": "tsc --noEmit" }, "dependencies": { - "@ghimtech/tax-domain": "workspace:*", - "@ghimtech/ui": "workspace:*", - "next": "^15.1.6", + "next": "^15.5.24", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -23,9 +18,6 @@ "@types/node": "^22.10.7", "@types/react": "^19.0.7", "@types/react-dom": "^19.0.3", - "autoprefixer": "^10.4.20", - "postcss": "^8.5.1", - "tailwindcss": "^3.4.17", "typescript": "^5.7.3" } } diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs deleted file mode 100644 index 2aa7205..0000000 --- a/apps/web/postcss.config.mjs +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/apps/web/src/app/(app)/admin/page.tsx b/apps/web/src/app/(app)/admin/page.tsx deleted file mode 100644 index 1263473..0000000 --- a/apps/web/src/app/(app)/admin/page.tsx +++ /dev/null @@ -1,195 +0,0 @@ -"use client"; -import { useState } from "react"; -import { - Alert, - Badge, - Button, - Card, - CardBody, - CardHeader, - CardTitle, - Field, - Input, - Select, - Skeleton, - Table, - TCell, - THead, - TRow, -} from "@ghimtech/ui"; -import { api } from "@/lib/api"; -import { useApi } from "@/lib/use-api"; - -interface UserRow { - id: string; - email: string; - name: string; - role: string; - disabled: boolean; - mfaEnrolled: boolean; -} - -export default function AdminPage() { - const users = useApi("/users"); - const [form, setForm] = useState({ - email: "", - name: "", - role: "PREPARER", - temporaryPassword: "", - }); - const [notice, setNotice] = useState<{ tone: "success" | "danger"; text: string }>(); - const [busy, setBusy] = useState(false); - - async function createUser(event: React.FormEvent) { - event.preventDefault(); - setBusy(true); - setNotice(undefined); - try { - await api("/users", { method: "POST", body: form }); - setNotice({ - tone: "success", - text: `${form.email} created. Share the temporary password securely — a reset and MFA enrollment are forced at first sign-in.`, - }); - setForm({ email: "", name: "", role: "PREPARER", temporaryPassword: "" }); - await users.refetch(); - } catch (err) { - setNotice({ tone: "danger", text: (err as Error).message }); - } finally { - setBusy(false); - } - } - - async function disable(id: string) { - await api(`/users/${id}/disable`, { method: "POST" }); - await users.refetch(); - } - - return ( -
-
-

Administration

-

User management and platform configuration.

-
- - {notice && {notice.text}} - - - - Create user - - -
- - {(id) => ( - setForm({ ...form, email: e.target.value })} - /> - )} - - - {(id) => ( - setForm({ ...form, name: e.target.value })} - /> - )} - - - {(id) => ( - - )} - - - {(id) => ( - setForm({ ...form, temporaryPassword: e.target.value })} - /> - )} - -
- -
-
-
-
- - - - Users - - {users.loading ? ( - - - - ) : ( - - - - {(users.data ?? []).map((user) => ( - - {user.name} - {user.email} - - {user.role} - - - - {user.mfaEnrolled ? "Enrolled" : "Pending"} - - - - - {user.disabled ? "Disabled" : "Active"} - - - - {!user.disabled && ( - - )} - - - ))} - -
- )} -
- - - - E-file provider - - -

- Active provider: mock — the deterministic development - provider. Sandbox and real authorized-transmitter adapters are configured via{" "} - GHIMTECH_EFILE_PROVIDER; provider-specific - code never touches core business logic. -

-
-
-
- ); -} diff --git a/apps/web/src/app/(app)/audit/page.tsx b/apps/web/src/app/(app)/audit/page.tsx deleted file mode 100644 index fe03f07..0000000 --- a/apps/web/src/app/(app)/audit/page.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"use client"; -import { - Alert, - Badge, - Card, - CardBody, - CardHeader, - CardTitle, - Skeleton, - Table, - TCell, - THead, - TRow, -} from "@ghimtech/ui"; -import { useApi } from "@/lib/use-api"; - -interface AuditRow { - action: string; - actorId: string; - actorRole: string; - entityType?: string; - entityId?: string; - occurredAt: string; - hash: string; -} - -export default function AuditPage() { - const events = useApi("/audit?limit=100"); - const verify = useApi<{ valid: boolean; length: number }>("/audit/verify"); - - return ( -
-
-

Audit log

-

- Append-only, hash-chained record of every sensitive action. -

-
- - {verify.data && ( - - {verify.data.valid - ? `Verified — ${verify.data.length} events form an unbroken hash chain.` - : "INTEGRITY FAILURE — the audit chain does not verify. Investigate immediately."} - - )} - {events.error && {events.error}} - - - - Recent events - - {events.loading ? ( - - - - ) : ( - - - - {[...(events.data ?? [])].reverse().map((event) => ( - - - {event.occurredAt.slice(0, 19).replace("T", " ")} - - - - {event.action} - - - {event.actorRole} - - {event.entityType ? `${event.entityType}` : "—"} - - - {event.hash.slice(0, 12)}… - - - ))} - -
- )} -
-
- ); -} diff --git a/apps/web/src/app/(app)/clients/[id]/page.tsx b/apps/web/src/app/(app)/clients/[id]/page.tsx deleted file mode 100644 index 4e614e2..0000000 --- a/apps/web/src/app/(app)/clients/[id]/page.tsx +++ /dev/null @@ -1,146 +0,0 @@ -"use client"; -import Link from "next/link"; -import { use, useState } from "react"; -import { useRouter } from "next/navigation"; -import { - Alert, - Button, - Card, - CardBody, - CardHeader, - CardTitle, - EmptyState, - Skeleton, - StatusBadge, - Table, - TCell, - THead, - TRow, -} from "@ghimtech/ui"; -import { api } from "@/lib/api"; -import { useApi } from "@/lib/use-api"; - -interface ClientDetail { - id: string; - firstName: string; - lastName: string; - email: string; - phone?: string; - tinMasked: string; - dateOfBirth: string; - address: { line1: string; city: string; state: string; zip: string }; -} -interface ReturnRow { - id: string; - taxYear: number; - status: string; - updatedAt: string; -} - -export default function ClientDetailPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = use(params); - const router = useRouter(); - const client = useApi(`/clients/${id}`); - const returns = useApi(`/returns?clientId=${id}`); - const [error, setError] = useState(); - const [busy, setBusy] = useState(false); - - async function startReturn() { - setBusy(true); - setError(undefined); - try { - const created = await api<{ id: string }>("/returns", { - method: "POST", - body: { clientId: id, taxYear: 2025, filingStatus: "SINGLE", includePennsylvania: true }, - }); - router.push(`/returns/${created.id}`); - } catch (err) { - setError((err as Error).message); - setBusy(false); - } - } - - if (client.loading) return ; - if (client.error) return {client.error}; - const c = client.data!; - - return ( -
-
-
-

- {c.lastName}, {c.firstName} -

-

- {c.email} · SSN {c.tinMasked} -

-
- -
- - {error && {error}} - -
- - - Profile - - -

- Address: {c.address.line1}, {c.address.city},{" "} - {c.address.state} {c.address.zip} -

-

- Date of birth: {c.dateOfBirth} -

-

- Phone: {c.phone ?? "—"} -

-
-
- -
- - - Returns - - {returns.loading ? ( - - - - ) : (returns.data ?? []).length === 0 ? ( - - - - ) : ( - - - - {(returns.data ?? []).map((ret) => ( - - {ret.taxYear} - - - - {ret.updatedAt.slice(0, 10)} - - - Open workspace → - - - - ))} - -
- )} -
-
-
-
- ); -} diff --git a/apps/web/src/app/(app)/clients/page.tsx b/apps/web/src/app/(app)/clients/page.tsx deleted file mode 100644 index 3817e76..0000000 --- a/apps/web/src/app/(app)/clients/page.tsx +++ /dev/null @@ -1,260 +0,0 @@ -"use client"; -import Link from "next/link"; -import { useState } from "react"; -import { - Alert, - Button, - Card, - CardBody, - CardHeader, - CardTitle, - EmptyState, - Field, - Input, - Skeleton, - Table, - TCell, - THead, - TRow, -} from "@ghimtech/ui"; -import { api } from "@/lib/api"; -import { useApi } from "@/lib/use-api"; - -interface ClientRow { - id: string; - firstName: string; - lastName: string; - email: string; - tinMasked: string; - tags: string[]; - createdAt: string; -} - -export default function ClientsPage() { - const { data, error, loading, refetch } = useApi("/clients"); - const [showForm, setShowForm] = useState(false); - const [formError, setFormError] = useState(); - const [busy, setBusy] = useState(false); - const [form, setForm] = useState({ - firstName: "", - lastName: "", - email: "", - tin: "", - dateOfBirth: "", - line1: "", - city: "", - state: "PA", - zip: "", - }); - - function set(key: K, value: string) { - setForm((f) => ({ ...f, [key]: value })); - } - - async function createClient(event: React.FormEvent) { - event.preventDefault(); - setBusy(true); - setFormError(undefined); - try { - await api("/clients", { - method: "POST", - body: { - name: { firstName: form.firstName, lastName: form.lastName }, - email: form.email, - tin: form.tin, - dateOfBirth: form.dateOfBirth, - address: { line1: form.line1, city: form.city, state: form.state, zip: form.zip }, - }, - }); - setShowForm(false); - setForm({ - firstName: "", - lastName: "", - email: "", - tin: "", - dateOfBirth: "", - line1: "", - city: "", - state: "PA", - zip: "", - }); - await refetch(); - } catch (err) { - setFormError((err as Error).message); - } finally { - setBusy(false); - } - } - - return ( -
-
-
-

Clients

-

Taxpayers served by GhimTech Tax.

-
- -
- - {error && {error}} - - {showForm && ( - - - New client - - -
- {formError && ( -
- {formError} -
- )} - - {(id) => ( - set("firstName", e.target.value)} - /> - )} - - - {(id) => ( - set("lastName", e.target.value)} - /> - )} - - - {(id) => ( - set("email", e.target.value)} - /> - )} - - - {(id) => ( - set("tin", e.target.value)} - /> - )} - - - {(id) => ( - set("dateOfBirth", e.target.value)} - /> - )} - - - {(id) => ( - set("line1", e.target.value)} - /> - )} - - - {(id) => ( - set("city", e.target.value)} - /> - )} - -
- - {(id) => ( - set("state", e.target.value.toUpperCase())} - /> - )} - - - {(id) => ( - set("zip", e.target.value)} - /> - )} - -
-
- -
-
-
-
- )} - - - {loading ? ( - - - - ) : (data ?? []).length === 0 ? ( - - - - ) : ( - - - - {(data ?? []).map((client) => ( - - - - {client.lastName}, {client.firstName} - - - {client.email} - {client.tinMasked} - {client.tags.join(", ")} - {client.createdAt.slice(0, 10)} - - ))} - -
- )} -
-
- ); -} diff --git a/apps/web/src/app/(app)/dashboard/page.tsx b/apps/web/src/app/(app)/dashboard/page.tsx deleted file mode 100644 index 89ea496..0000000 --- a/apps/web/src/app/(app)/dashboard/page.tsx +++ /dev/null @@ -1,100 +0,0 @@ -"use client"; -import Link from "next/link"; -import { Alert, Card, CardBody, CardHeader, CardTitle, Skeleton, StatusBadge } from "@ghimtech/ui"; -import { useApi } from "@/lib/use-api"; - -interface Dashboard { - returnsByStatus: Record; - totals: { - clients: number; - returns: number; - awaitingReview: number; - awaitingSignature: number; - readyToFile: number; - transmitted: number; - accepted: number; - rejected: number; - acceptanceRate: number | null; - }; -} - -const STAT_CARDS: Array<{ key: keyof Dashboard["totals"]; label: string }> = [ - { key: "clients", label: "Clients" }, - { key: "returns", label: "Returns" }, - { key: "awaitingReview", label: "Awaiting review" }, - { key: "awaitingSignature", label: "Awaiting signature" }, - { key: "readyToFile", label: "Ready to file" }, - { key: "transmitted", label: "In transmission" }, - { key: "accepted", label: "Accepted" }, - { key: "rejected", label: "Rejected / correcting" }, -]; - -export default function DashboardPage() { - const { data, error, loading } = useApi("/dashboard"); - - return ( -
-
-

Dashboard

-

Filing season at a glance.

-
- - {error && {error}} - -
- {STAT_CARDS.map((stat) => ( - - - {loading ? ( - - ) : ( - <> -

- {data?.totals[stat.key] ?? 0} - {stat.key === "accepted" && data?.totals.acceptanceRate != null && ( - - {Math.round(data.totals.acceptanceRate * 100)}% - - )} -

-

- {stat.label} -

- - )} -
-
- ))} -
- - - - Returns by status - - - {loading ? ( - - ) : ( -
- {Object.entries(data?.returnsByStatus ?? {}) - .filter(([, count]) => count > 0) - .map(([status, count]) => ( - - - - {count} - - - ))} - {Object.values(data?.returnsByStatus ?? {}).every((v) => v === 0) && ( -

- No returns yet — create a client, then start their 2025 return. -

- )} -
- )} -
-
-
- ); -} diff --git a/apps/web/src/app/(app)/documents/page.tsx b/apps/web/src/app/(app)/documents/page.tsx deleted file mode 100644 index 83f36c4..0000000 --- a/apps/web/src/app/(app)/documents/page.tsx +++ /dev/null @@ -1,170 +0,0 @@ -"use client"; -/** - * Document vault: upload with client-side size guard (the API re-validates - * type by magic bytes and scans content), OCR suggestions, and verification. - */ -import { useRef, useState } from "react"; -import { - Alert, - Badge, - Button, - Card, - CardBody, - CardHeader, - CardTitle, - EmptyState, - Select, - Skeleton, - Table, - TCell, - THead, - TRow, -} from "@ghimtech/ui"; -import { api, getUser } from "@/lib/api"; -import { useApi } from "@/lib/use-api"; - -interface DocRow { - id: string; - clientId: string; - category: string; - status: string; - filename: string; - sizeBytes: number; - createdAt: string; -} -interface ClientRow { - id: string; - firstName: string; - lastName: string; -} - -export default function DocumentsPage() { - const user = getUser(); - const isClient = user?.role === "CLIENT"; - const clients = useApi(isClient ? null : "/clients"); - const docs = useApi("/documents"); - const [clientId, setClientId] = useState(""); - const [notice, setNotice] = useState<{ tone: "success" | "danger"; text: string }>(); - const [busy, setBusy] = useState(false); - const fileInput = useRef(null); - - async function upload(file: File) { - setBusy(true); - setNotice(undefined); - try { - if (file.size > 25 * 1024 * 1024) throw new Error("Files are limited to 25 MB"); - const buffer = await file.arrayBuffer(); - const contentBase64 = btoa( - Array.from(new Uint8Array(buffer), (b) => String.fromCharCode(b)).join(""), - ); - const result = await api<{ category: string }>("/documents", { - method: "POST", - body: { - filename: file.name, - contentBase64, - ...(isClient ? {} : { clientId }), - }, - }); - setNotice({ - tone: "success", - text: `Uploaded and scanned. Detected category: ${result.category}. OCR values await human verification.`, - }); - await docs.refetch(); - } catch (err) { - setNotice({ tone: "danger", text: (err as Error).message }); - } finally { - setBusy(false); - if (fileInput.current) fileInput.current.value = ""; - } - } - - return ( -
-
-

Document vault

-

- Encrypted storage with malware scanning; OCR suggestions always require human verification - before entering a return. -

-
- - {notice && {notice.text}} - - - - Upload - - - {!isClient && ( -
- - -
- )} -
- { - const file = e.target.files?.[0]; - if (file) void upload(file); - }} - /> - -
-

PDF, PNG, JPEG, HEIC · 25 MB max

-
-
- - - {docs.loading ? ( - - - - ) : (docs.data ?? []).length === 0 ? ( - - - - ) : ( - - - - {(docs.data ?? []).map((doc) => ( - - {doc.filename} - - {doc.category.replaceAll("_", " ")} - - - - {doc.status.replaceAll("_", " ")} - - - {Math.ceil(doc.sizeBytes / 1024)} KB - {doc.createdAt.slice(0, 10)} - - ))} - -
- )} -
-
- ); -} diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx deleted file mode 100644 index 64ed336..0000000 --- a/apps/web/src/app/(app)/layout.tsx +++ /dev/null @@ -1,112 +0,0 @@ -"use client"; -/** - * Authenticated application shell: sidebar navigation scoped by role, - * top bar with session controls, responsive down to mobile. - */ -import Link from "next/link"; -import { usePathname, useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { Button, Wordmark, cx } from "@ghimtech/ui"; -import { api, clearSession, getToken, getUser, type SessionUser } from "@/lib/api"; - -const NAV: Array<{ href: string; label: string; roles: SessionUser["role"][] }> = [ - { href: "/dashboard", label: "Dashboard", roles: ["ADMIN", "PREPARER", "REVIEWER", "AUDITOR"] }, - { href: "/clients", label: "Clients", roles: ["ADMIN", "PREPARER", "REVIEWER", "AUDITOR"] }, - { - href: "/returns", - label: "Returns", - roles: ["ADMIN", "PREPARER", "REVIEWER", "AUDITOR", "CLIENT"], - }, - { - href: "/documents", - label: "Documents", - roles: ["ADMIN", "PREPARER", "REVIEWER", "AUDITOR", "CLIENT"], - }, - { href: "/audit", label: "Audit log", roles: ["ADMIN", "AUDITOR"] }, - { href: "/admin", label: "Administration", roles: ["ADMIN"] }, -]; - -export default function AppLayout({ children }: { children: React.ReactNode }) { - const router = useRouter(); - const pathname = usePathname(); - const [user, setUser] = useState(null); - const [ready, setReady] = useState(false); - - useEffect(() => { - if (!getToken()) { - router.replace("/sign-in"); - return; - } - setUser(getUser()); - setReady(true); - }, [router]); - - async function signOut() { - try { - await api("/auth/logout", { method: "POST" }); - } catch { - // Session may already be gone; clearing locally is what matters. - } - clearSession(); - router.replace("/sign-in"); - } - - if (!ready) return null; - - const links = NAV.filter((item) => !user || item.roles.includes(user.role)); - - return ( -
- - -
-
-
- - - -
-
- Private preparation environment — all activity is audited -
-
- {user && ( - - {user.name} · {user.role.toLowerCase()} - - )} - -
-
-
{children}
-
-
- ); -} diff --git a/apps/web/src/app/(app)/returns/[id]/page.tsx b/apps/web/src/app/(app)/returns/[id]/page.tsx deleted file mode 100644 index 7fcb8bc..0000000 --- a/apps/web/src/app/(app)/returns/[id]/page.tsx +++ /dev/null @@ -1,820 +0,0 @@ -"use client"; -/** - * Return workspace: intake, calculation with traces, diagnostics, lifecycle - * actions, signature, and e-filing — the preparer's (and client's) single - * view of a return. - */ -import { use, useEffect, useState } from "react"; -import { - Alert, - Badge, - Button, - Card, - CardBody, - CardHeader, - CardTitle, - Field, - Input, - Money, - Select, - SeverityBadge, - Skeleton, - StatusBadge, - cx, -} from "@ghimtech/ui"; -import { api, apiUrl, getToken, getUser } from "@/lib/api"; -import { useApi } from "@/lib/use-api"; - -interface W2 { - id: string; - employerName: string; - wages: number; - federalWithholding: number; - socialSecurityWages: number; - medicareWages: number; - stateWages: number; - stateWithholding: number; - stateCode?: string; - localWithholding: number; - belongsToSpouse: boolean; -} - -interface ReturnModel { - returnId: string; - taxYear: number; - filingStatus: string; - w2s: W2[]; - dependents: unknown[]; - pennsylvania?: { - residencyStatus: string; - psdCode?: string; - schoolDistrict?: string; - claimTaxForgiveness: boolean; - spEligibilityOtherIncome: number; - }; - eitc: { claiming: boolean; disqualified: boolean }; - [key: string]: unknown; -} - -interface ReturnDetail { - id: string; - clientId: string; - taxYear: number; - status: string; - latestSnapshotHash?: string; - allowedTransitions: string[]; - model: ReturnModel; -} - -interface TraceEntry { - lineId: string; - label: string; - valueDollars: number; - formula: string; - ruleVersion: string; -} - -interface Calculation { - snapshotHash: string; - federal: { - adjustedGrossIncome: number; - taxableIncome: number; - incomeTax: number; - totalTax: number; - totalPayments: number; - earnedIncomeCredit: number; - childTaxCredit: number; - additionalChildTaxCredit: number; - refund: number; - balanceDue: number; - trace: { entries: TraceEntry[] }; - }; - pennsylvania?: { - totalTaxableIncome: number; - taxLiability: number; - taxForgiveness: number; - totalPayments: number; - refund: number; - balanceDue: number; - trace: { entries: TraceEntry[] }; - }; - diagnostics: Array<{ code: string; severity: string; message: string; jurisdiction: string }>; -} - -interface Submission { - id: string; - providerSubmissionId: string; - state: string; - submittedAt: string; - acknowledgment?: { - accepted: boolean; - agencyTrackingId?: string; - rejections: Array<{ - code: string; - message: string; - explanation?: string; - correctiveAction?: string; - }>; - }; -} - -const EDITABLE_STATUSES = new Set([ - "DRAFT", - "INCOMPLETE", - "READY_FOR_PREPARER_REVIEW", - "PREPARER_REVIEWED", - "READY_FOR_REVIEWER", - "REVIEW_CHANGES_REQUESTED", - "APPROVED", - "AWAITING_CLIENT_REVIEW", - "AWAITING_SIGNATURE", - "VALIDATION_FAILED", - "REJECTED", - "CORRECTION_REQUIRED", -]); - -const dollarsToCents = (value: string) => Math.round(Number(value || "0") * 100); -const centsToDollars = (cents: number) => (cents / 100).toFixed(2); - -export default function ReturnWorkspace({ params }: { params: Promise<{ id: string }> }) { - const { id } = use(params); - const detail = useApi(`/returns/${id}`); - const submissions = useApi(`/returns/${id}/submissions`); - const user = getUser(); - - const [model, setModel] = useState(); - const [calc, setCalc] = useState(); - const [notice, setNotice] = useState<{ tone: "success" | "danger" | "info"; text: string }>(); - const [busy, setBusy] = useState(); - const [showTrace, setShowTrace] = useState(false); - const [signatureText, setSignatureText] = useState(""); - - useEffect(() => { - if (detail.data) setModel(detail.data.model); - }, [detail.data]); - - const status = detail.data?.status ?? "…"; - const editable = - EDITABLE_STATUSES.has(status) && user?.role !== "AUDITOR" && user?.role !== "CLIENT"; - - async function run(label: string, fn: () => Promise) { - setBusy(label); - setNotice(undefined); - try { - await fn(); - } catch (err) { - setNotice({ tone: "danger", text: (err as Error).message }); - } finally { - setBusy(undefined); - } - } - - const saveModel = () => - run("save", async () => { - await api(`/returns/${id}/model`, { method: "PUT", body: model }); - setNotice({ tone: "success", text: "Return data saved." }); - await detail.refetch(); - }); - - const calculate = () => - run("calculate", async () => { - const result = await api(`/returns/${id}/calculate`, { method: "POST" }); - setCalc(result); - const blocking = result.diagnostics.filter((d) => d.severity === "ERROR").length; - setNotice( - blocking > 0 - ? { tone: "danger", text: `Calculated with ${blocking} blocking diagnostic(s).` } - : { tone: "success", text: "Calculation complete — no blocking diagnostics." }, - ); - await detail.refetch(); - }); - - const transition = (toStatus: string) => - run(toStatus, async () => { - await api(`/returns/${id}/transition`, { method: "POST", body: { toStatus } }); - await detail.refetch(); - }); - - const sign = () => - run("sign", async () => { - await api(`/returns/${id}/signatures`, { - method: "POST", - body: { - signatureText, - consentAcknowledged: true, - reviewedSnapshotHash: detail.data?.latestSnapshotHash, - }, - }); - setNotice({ tone: "success", text: "Signature captured — the return is now locked." }); - await detail.refetch(); - }); - - const efile = () => - run("efile", async () => { - await api(`/returns/${id}/efile`, { method: "POST" }); - setNotice({ tone: "info", text: "Transmitted — awaiting agency acknowledgment." }); - await detail.refetch(); - await submissions.refetch(); - }); - - const poll = () => - run("poll", async () => { - const result = await api<{ status: string }>(`/returns/${id}/efile/poll`, { method: "POST" }); - setNotice({ - tone: "info", - text: `Acknowledgment status: ${result.status.replaceAll("_", " ")}`, - }); - await detail.refetch(); - await submissions.refetch(); - }); - - if (detail.loading || !model) return ; - if (detail.error) return {detail.error}; - const ret = detail.data!; - - function updateW2(index: number, patch: Partial) { - setModel((m) => { - if (!m) return m; - const w2s = m.w2s.map((w, i) => (i === index ? { ...w, ...patch } : w)); - return { ...m, w2s }; - }); - } - - return ( -
-
-
-

- Tax year {ret.taxYear} return -

-

- Snapshot{" "} - - {ret.latestSnapshotHash - ? `${ret.latestSnapshotHash.slice(0, 12)}…` - : "not calculated"} - -

-
-
- {editable && ( - <> - - - - )} - {ret.allowedTransitions - .filter((t) => t !== "SIGNED") - .map((toStatus) => ( - - ))} - {(status === "READY_TO_EFILE" || status === "RESUBMISSION_READY") && - user?.role !== "CLIENT" && ( - - )} - {(status === "ACKNOWLEDGMENT_PENDING" || status === "RESUBMITTED") && ( - - )} - { - e.preventDefault(); - void run("package", async () => { - const response = await fetch(apiUrl(`/returns/${id}/package?format=html`), { - headers: { authorization: `Bearer ${getToken()}` }, - }); - if (!response.ok) throw new Error("Run calculations before opening the package"); - const html = await response.text(); - const win = window.open("", "_blank"); - win?.document.write(html); - win?.document.close(); - }); - }} - className="inline-flex h-8 items-center rounded-lg px-3 text-[13px] font-medium text-brand-700 hover:bg-brand-50 dark:text-brand-300" - > - View forms ↗ - -
-
- - {notice && {notice.text}} - -
- {/* ---- Intake ---- */} -
- - - setModel((m) => - m - ? { - ...m, - w2s: [ - ...m.w2s, - { - id: `w2-${m.w2s.length + 1}-${Date.now().toString(36)}`, - employerName: "", - wages: 0, - federalWithholding: 0, - socialSecurityWages: 0, - medicareWages: 0, - stateWages: 0, - stateWithholding: 0, - stateCode: "PA", - localWithholding: 0, - belongsToSpouse: false, - }, - ], - } - : m, - ) - } - > - + Add W-2 - - ) : undefined - } - > - Intake — income - - -
- - {(fid) => ( - - )} - - - {(fid) => ( - - )} - -
- - {model.w2s.length === 0 && ( -

No W-2s yet. Add one to begin.

- )} - {model.w2s.map((w2, index) => ( -
- - W-2 #{index + 1} - -
- - {(fid) => ( - updateW2(index, { employerName: e.target.value })} - /> - )} - - - {(fid) => ( - { - const cents = dollarsToCents(e.target.value); - updateW2(index, { - wages: cents, - socialSecurityWages: cents, - medicareWages: cents, - stateWages: cents, - }); - }} - /> - )} - - - {(fid) => ( - - updateW2(index, { federalWithholding: dollarsToCents(e.target.value) }) - } - /> - )} - - - {(fid) => ( - - updateW2(index, { stateWages: dollarsToCents(e.target.value) }) - } - /> - )} - - - {(fid) => ( - - updateW2(index, { stateWithholding: dollarsToCents(e.target.value) }) - } - /> - )} - - - {(fid) => ( - - )} - -
-
- ))} - - {model.pennsylvania && ( -
- - {(fid) => ( - - )} - - - {(fid) => ( - - setModel({ - ...model, - pennsylvania: { - ...model.pennsylvania!, - psdCode: e.target.value || undefined, - }, - }) - } - /> - )} - - - {(fid) => ( - - )} - -
- )} -
-
- - {/* ---- Diagnostics ---- */} - {calc && ( - - - Diagnostics ({calc.diagnostics.length}) - - - {calc.diagnostics.length === 0 && ( -

No diagnostics — the return is clean.

- )} - {calc.diagnostics.map((diag, i) => ( -
- -
-

{diag.message}

-

- {diag.jurisdiction} · {diag.code} -

-
-
- ))} -
-
- )} -
- - {/* ---- Results ---- */} -
- - setShowTrace((v) => !v)}> - {showTrace ? "Hide trace" : "Show trace"} - - ) : undefined - } - > - Federal (Form 1040) - - - {!calc ? ( -

Run a calculation to see results.

- ) : ( -
- {( - [ - ["Adjusted gross income", calc.federal.adjustedGrossIncome], - ["Taxable income", calc.federal.taxableIncome], - ["Income tax", calc.federal.incomeTax], - ["Child tax credit", calc.federal.childTaxCredit], - ["Earned income credit", calc.federal.earnedIncomeCredit], - ["Total tax", calc.federal.totalTax], - ["Total payments", calc.federal.totalPayments], - ] as Array<[string, number]> - ).map(([label, cents]) => ( -
-
{label}
-
- -
-
- ))} -
0 - ? "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300" - : "bg-rose-50 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300", - )} - > -
{calc.federal.refund > 0 ? "Federal refund" : "Federal balance due"}
-
- 0 ? calc.federal.refund : calc.federal.balanceDue - } - /> -
-
-
- )} -
-
- - {calc?.pennsylvania && ( - - - Pennsylvania (PA-40) - - -
- {( - [ - ["PA taxable income", calc.pennsylvania.totalTaxableIncome], - ["Tax at 3.07%", calc.pennsylvania.taxLiability], - ["Tax forgiveness", calc.pennsylvania.taxForgiveness], - ["Payments", calc.pennsylvania.totalPayments], - ] as Array<[string, number]> - ).map(([label, cents]) => ( -
-
{label}
-
- -
-
- ))} -
0 || calc.pennsylvania.balanceDue === 0 - ? "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300" - : "bg-rose-50 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300", - )} - > -
{calc.pennsylvania.balanceDue > 0 ? "PA balance due" : "PA refund"}
-
- 0 - ? calc.pennsylvania.balanceDue - : calc.pennsylvania.refund - } - /> -
-
-
-
-
- )} - - {showTrace && calc && ( - - - Calculation trace - - - {[...calc.federal.trace.entries, ...(calc.pennsylvania?.trace.entries ?? [])].map( - (entry) => ( -
-
- {entry.label} - - ${entry.valueDollars.toLocaleString("en-US")} - -
-

{entry.formula}

-

- {entry.lineId} · rules {entry.ruleVersion} -

-
- ), - )} -
-
- )} - - {/* ---- Signature ---- */} - {status === "AWAITING_SIGNATURE" && - (user?.role === "CLIENT" || user?.role === "ADMIN") && ( - - - Sign your return - - -

- By signing, you declare under penalties of perjury that you examined this return - and that it is true, correct, and complete, and you authorize GhimTech to - transmit it through its authorized e-file provider. -

- - {(fid) => ( - setSignatureText(e.target.value)} - placeholder="Full legal name" - /> - )} - - -
-
- )} - - {/* ---- Submissions ---- */} - {(submissions.data ?? []).length > 0 && ( - - - Filing submissions - - - {(submissions.data ?? []).map((sub) => ( -
-
- {sub.providerSubmissionId} - -
-

- {sub.submittedAt.slice(0, 19).replace("T", " ")} -

- {sub.acknowledgment?.agencyTrackingId && ( -

- Tracking:{" "} - {sub.acknowledgment.agencyTrackingId} -

- )} - {sub.acknowledgment?.rejections.map((rej) => ( -
-

- {rej.code} -

-

{rej.explanation ?? rej.message}

- {rej.correctiveAction && ( -

Fix: {rej.correctiveAction}

- )} -
- ))} -
- ))} -
-
- )} -
-
-
- ); -} diff --git a/apps/web/src/app/(app)/returns/page.tsx b/apps/web/src/app/(app)/returns/page.tsx deleted file mode 100644 index 9e43104..0000000 --- a/apps/web/src/app/(app)/returns/page.tsx +++ /dev/null @@ -1,93 +0,0 @@ -"use client"; -import Link from "next/link"; -import { Suspense } from "react"; -import { useSearchParams } from "next/navigation"; -import { - Alert, - Card, - CardBody, - EmptyState, - Skeleton, - StatusBadge, - Table, - TCell, - THead, - TRow, -} from "@ghimtech/ui"; -import { useApi } from "@/lib/use-api"; - -interface ReturnRow { - id: string; - clientId: string; - taxYear: number; - status: string; - updatedAt: string; -} - -function ReturnsList() { - const params = useSearchParams(); - const status = params.get("status"); - const { data, error, loading } = useApi( - `/returns${status ? `?status=${encodeURIComponent(status)}` : ""}`, - ); - - return ( -
-
-

Returns

-

- {status ? ( - <> - Filtered by - - ) : ( - "Every return in the current filing season." - )} -

-
- {error && {error}} - - {loading ? ( - - - - ) : (data ?? []).length === 0 ? ( - - - - ) : ( - - - - {(data ?? []).map((ret) => ( - - {ret.taxYear} - - - - {ret.updatedAt.slice(0, 10)} - - - Open → - - - - ))} - -
- )} -
-
- ); -} - -export default function ReturnsPage() { - return ( - }> - - - ); -} diff --git a/apps/web/src/app/about/page.tsx b/apps/web/src/app/about/page.tsx new file mode 100644 index 0000000..d318a3a --- /dev/null +++ b/apps/web/src/app/about/page.tsx @@ -0,0 +1,111 @@ +import { PageIntro, Eyebrow, ProjectCTA, Action } from "@/components/primitives"; +import { pageMetadata } from "@/lib/site"; +export const metadata = pageMetadata( + "About Durga Ghimeray", + "Meet the founder behind GhimTech and the philosophy behind software built around real business operations.", + "/about", +); +const principles = [ + [ + "Software should fit the business.", + "Start with how the company actually works. Preserve useful habits and improve the processes that create unnecessary work.", + ], + [ + "Automate the repetitive.", + "Use software to handle predictable steps so people can focus on decisions, relationships, and exceptions.", + ], + [ + "Keep humans in control.", + "Make rules understandable. Give people visibility, a way to correct information, and a clear path when something goes wrong.", + ], + [ + "Build what matters.", + "A smaller system that handles the right workflow is more useful than a long feature list nobody needs.", + ], + [ + "Design for actual operations.", + "Expect changing schedules, missing information, and people using the system under time pressure. Those are design conditions.", + ], + [ + "Improve continuously.", + "Business software needs ownership after launch. Refine it as the company learns and the work changes.", + ], +]; +export default function About() { + return ( + <> + +

+ GhimTech is my personal technology brand and software studio. A place to turn operational + problems into useful products. +

+
+
+ +
+ Why GhimTech exists +

Too much work happens between the tools.

+

+ Someone copies the same information twice. Someone remembers to send the reminder. + Someone keeps a spreadsheet that explains what the official system cannot. +

+

+ I’m interested in building the missing structure: software that understands the work, + connects the steps, and gives people a better way to operate. +

+

+ GhimTech brings that focus to custom business systems, workflow automation, and products + that can grow alongside a company. +

+
+
+
+
+ From idea to operation +

+ Build close +
+ to the business. +

+
+
+

+ CyanjelHomeCare LLC is fully powered by GhimTech. That relationship grounds the studio + in the realities of an operating business, where software has to account for people, + information, and administrative follow-through. +

+

+ The ambition is to keep building products and systems that solve those kinds of + problems, across industries and over the long term. +

+ + Explore the CyanjelHomeCare story + +
+
+
+
+
+ Our principles +

A practical view of software.

+
+
+ {principles.map(([title, copy]) => ( +
+

{title}

+

{copy}

+
+ ))} +
+ + + ); +} diff --git a/apps/web/src/app/api/project/route.ts b/apps/web/src/app/api/project/route.ts new file mode 100644 index 0000000..35a0228 --- /dev/null +++ b/apps/web/src/app/api/project/route.ts @@ -0,0 +1,135 @@ +import { createHmac } from "node:crypto"; +import { validateEnquiry } from "@/lib/enquiry.mjs"; +import { siteUrl } from "@/lib/site"; +export const runtime = "nodejs"; +const unavailable = + "The enquiry service is temporarily unavailable. Your details have not been confirmed as received. Please keep this page open and try again later."; +function reply(status: number, message: string, extra: Record = {}) { + return Response.json( + { message, ...extra }, + { + status, + headers: { + "Cache-Control": "no-store", + ...(status === 429 ? { "Retry-After": "3600" } : {}), + }, + }, + ); +} +async function readBody(request: Request) { + const reader = request.body?.getReader(); + if (!reader) throw new Error("empty"); + let size = 0; + const chunks: Uint8Array[] = []; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > 20000) { + await reader.cancel(); + throw new Error("large"); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); +} +export async function POST(request: Request) { + const origin = request.headers.get("origin"); + const expected = new URL(siteUrl).origin; + const local = process.env.NODE_ENV === "development" && origin === new URL(request.url).origin; + if (!origin || (origin !== expected && !local)) + return reply(403, "Please submit the form from the GhimTech website."); + if (!request.headers.get("content-type")?.startsWith("application/json")) + return reply(415, "Please send the form as JSON."); + const length = Number(request.headers.get("content-length") || 0); + if (length > 20000) return reply(413, "Please shorten the project details."); + let input: Record; + try { + input = await readBody(request); + } catch { + return reply(400, "Please check the form and try again."); + } + if (!input || typeof input !== "object" || Array.isArray(input)) + return reply(400, "Please check the form and try again."); + if (input.fax) return reply(400, "Unable to process this enquiry."); + const elapsed = Date.now() - Number(input.startedAt); + if (!Number.isFinite(elapsed) || elapsed < 2500 || elapsed > 86400000) + return reply( + 400, + "Please take a moment to review the form, then try again. If this page has been open for a day, reload it.", + ); + if ( + typeof input.requestId !== "string" || + !/^([0-9a-f]{8}-)([0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(input.requestId) + ) + return reply(400, "Please reload the form and try again."); + const { data, errors } = validateEnquiry(input); + if (Object.keys(errors).length) + return reply(422, "Please check the highlighted fields.", { errors }); + const webhook = process.env.PROJECT_WEBHOOK_URL; + const token = process.env.PROJECT_WEBHOOK_TOKEN; + const redis = process.env.UPSTASH_REDIS_REST_URL; + const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN; + const secret = process.env.RATE_LIMIT_SECRET; + if (!webhook || !token || !redis || !redisToken || !secret) return reply(503, unavailable); + try { + if (new URL(webhook).protocol !== "https:" || new URL(redis).protocol !== "https:") + return reply(503, unavailable); + const hash = (value: string) => createHmac("sha256", secret).update(value).digest("hex"); + // Only trust the platform-controlled client IP header on Vercel. + // Other hosts use a shared bucket until an explicit trusted proxy is configured. + const identity = process.env.VERCEL + ? request.headers.get("x-vercel-forwarded-for")?.split(",")[0]?.trim() || "unknown" + : "shared"; + const script = + "local a=redis.call('INCR',KEYS[1]); if a==1 then redis.call('EXPIRE',KEYS[1],3600) end; local b=redis.call('INCR',KEYS[2]); if b==1 then redis.call('EXPIRE',KEYS[2],3600) end; if a>20 or b>5 then return 0 end; return 1"; + const limited = await fetch(redis, { + method: "POST", + headers: { Authorization: "Bearer " + redisToken, "Content-Type": "application/json" }, + body: JSON.stringify([ + "EVAL", + script, + 2, + "ghimtech:ip:" + hash(identity), + "ghimtech:email:" + hash(data.email.toLowerCase()), + ]), + signal: AbortSignal.timeout(4000), + cache: "no-store", + }); + if (!limited.ok) throw new Error("rate-limit unavailable"); + const result = await limited.json(); + if (result.error || ![0, 1].includes(result.result)) throw new Error("rate-limit error"); + if (result.result === 0) + return reply( + 429, + "Too many enquiries have been sent. Please wait an hour before trying again.", + ); + const receipt = hash(input.requestId + JSON.stringify(data)); + const delivered = await fetch(webhook, { + method: "POST", + headers: { + Authorization: "Bearer " + token, + "Content-Type": "application/json", + "Idempotency-Key": receipt, + }, + body: JSON.stringify({ id: receipt, ...data, receivedAt: new Date().toISOString() }), + redirect: "error", + signal: AbortSignal.timeout(10000), + cache: "no-store", + }); + if (!delivered.ok) throw new Error("delivery failed"); + return reply(200, "Your project enquiry has been received."); + } catch { + return reply(503, unavailable); + } +} diff --git a/apps/web/src/app/apple-icon.tsx b/apps/web/src/app/apple-icon.tsx new file mode 100644 index 0000000..46321dc --- /dev/null +++ b/apps/web/src/app/apple-icon.tsx @@ -0,0 +1,22 @@ +import { ImageResponse } from "next/og"; +export const size = { width: 180, height: 180 }; +export const contentType = "image/png"; +export default function Icon() { + return new ImageResponse( +
+ G +
, + size, + ); +} diff --git a/apps/web/src/app/contact/page.tsx b/apps/web/src/app/contact/page.tsx new file mode 100644 index 0000000..b0d1fda --- /dev/null +++ b/apps/web/src/app/contact/page.tsx @@ -0,0 +1,39 @@ +import { PageIntro } from "@/components/primitives"; +import { ProjectForm } from "@/components/project-form"; +import { pageMetadata } from "@/lib/site"; +export const metadata = pageMetadata( + "Start a Project", + "Tell GhimTech about the workflow, manual process, or administrative bottleneck that is slowing your business down.", + "/contact", +); +export default function Contact() { + return ( + <> + +

+ Show us the workflow, spreadsheet, or administrative bottleneck that is becoming difficult + to manage. We’ll determine whether software can simplify it. +

+
+
+ + +
+ + ); +} diff --git a/apps/web/src/app/error.tsx b/apps/web/src/app/error.tsx new file mode 100644 index 0000000..dc4713c --- /dev/null +++ b/apps/web/src/app/error.tsx @@ -0,0 +1,13 @@ +"use client"; +export default function ErrorPage({ reset }: { reset: () => void }) { + return ( +
+

Something went wrong

+

Let’s try that again.

+

This page couldn’t load. You can retry without leaving the site.

+ +
+ ); +} diff --git a/apps/web/src/app/global-error.tsx b/apps/web/src/app/global-error.tsx new file mode 100644 index 0000000..0abe939 --- /dev/null +++ b/apps/web/src/app/global-error.tsx @@ -0,0 +1,19 @@ +"use client"; +export default function GlobalError({ reset }: { reset: () => void }) { + return ( + + +

This page couldn’t load.

+

Please try again.

+ + + + ); +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index e75f50e..5505f2a 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1,23 +1,2331 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - :root { - color-scheme: light; + --paper: #f5f3ed; + --ink: #202722; + --muted: #656b63; + --line: #d9dcd2; + --rust: #a34228; + --sage: #dfe5d9; + --dark: #24352d; + --white: #fffefa; + --font: Arial, Helvetica, sans-serif; +} +* { + box-sizing: border-box; +} +html { + scroll-behavior: smooth; + scroll-padding-top: 100px; +} +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font-family: var(--font); + font-size: 16px; + line-height: 1.65; + -webkit-font-smoothing: antialiased; +} +::selection { + background: #c9d7ba; + color: var(--ink); +} +a { + color: inherit; + text-decoration: none; +} +button, +input, +textarea, +select { + font: inherit; +} +button, +a, +input, +textarea, +select { + -webkit-tap-highlight-color: transparent; +} +button { + cursor: pointer; + color: inherit; +} +button:disabled { + cursor: wait; + opacity: 0.65; +} +a, +button { + touch-action: manipulation; +} +a:focus-visible, +button:focus-visible, +input:focus-visible, +textarea:focus-visible, +select:focus-visible { + outline: 3px solid var(--rust); + outline-offset: 5px; +} +p { + color: var(--muted); + margin: 0 0 24px; +} +h1, +h2, +h3, +h4 { + line-height: 1.1; + font-weight: 500; + letter-spacing: -0.045em; + margin: 0; +} +h1 { + font-size: clamp(44px, 5.1vw, 82px); +} +h2 { + font-size: clamp(36px, 3.55vw, 56px); +} +h3 { + font-size: 28px; +} +svg { + display: block; +} +img { + max-width: 100%; + height: auto; +} +button { + background: none; + border: 0; +} +a, +button { + transition: + background 0.2s, + color 0.2s, + transform 0.2s; +} +a:hover { + color: var(--rust); +} +.container { + width: min(1440px, calc(100% - 112px)); + margin-inline: auto; +} +.section { + padding-block: 112px; + border-bottom: 1px solid var(--line); +} +.eyebrow, +.mini-label, +.diagram-heading, +.proof-label, +.feature-top, +.diagram-note { + font-family: var(--font); + font-size: 10px; + line-height: 1.5; + letter-spacing: 0.13em; + font-weight: 600; + text-transform: uppercase; +} +.eyebrow { + display: flex; + align-items: center; + gap: 10px; + margin: 0 0 30px; + color: var(--muted); +} +.eyebrow > span { + height: 5px; + width: 5px; + background: var(--rust); +} +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 30px; + background: var(--ink); + color: var(--paper); + padding: 16px 23px; + font-size: 13px; + font-weight: 600; + line-height: 1.5; + min-height: 52px; + border: 1px solid var(--ink); + border-radius: 3px; + white-space: nowrap; +} +.button:hover { + background: var(--rust); + border-color: var(--rust); + color: white; +} +.button:active { + transform: translateY(1px); +} +.button-small { + padding: 11px 17px; + min-height: 43px; + font-size: 12px; + gap: 25px; +} +.text-link { + display: inline-flex; + align-items: center; + gap: 28px; + font-size: 13px; + font-weight: 600; + min-height: 48px; + border-bottom: 1px solid var(--line); +} +.text-link:hover { + border-color: var(--rust); +} +.text-link > span, +.button > span { + font-size: 20px; + line-height: 1; +} +.skip-link { + position: fixed; + top: 10px; + left: 20px; + transform: translateY(-200%); + background: var(--ink); + color: white; + padding: 12px 20px; + z-index: 100; +} +.skip-link:focus { + transform: none; +} +.header { + border-bottom: 1px solid var(--line); + background: var(--paper); + position: relative; + z-index: 30; +} +.header-inner { + min-height: 94px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} +.wordmark { + font-size: 25px; + font-weight: 700; + letter-spacing: -1.4px; + display: flex; + align-items: center; + line-height: 1; +} +.wordmark-dot { + color: var(--rust); +} +.brand-mark { + width: 27px; + height: 26px; + position: relative; + margin-right: 11px; + display: grid; + grid-template-columns: repeat(3, 5px); + gap: 3px; + transform: skewY(-18deg); +} +.brand-mark i { + height: 20px; + background: var(--ink); +} +.brand-mark i:nth-child(2) { + margin-top: 4px; +} +.brand-mark i:nth-child(3) { + margin-top: 8px; +} +.navigation { + display: flex; + gap: 34px; + align-items: center; + font-size: 12px; +} +.navigation > a[aria-current="page"] { + color: var(--rust); +} +.menu-toggle { + display: none; +} +.hero { + display: grid; + grid-template-columns: 1.12fr 1fr; + gap: 54px; + align-items: center; + padding-block: 95px 83px; +} +.hero .eyebrow { + font-size: 9px; + letter-spacing: 0.14em; +} +.hero h1 { + font-size: clamp(45px, 5vw, 78px); + line-height: 1.04; + letter-spacing: -0.055em; +} +.serif-word { + font-family: Georgia, serif; + font-style: italic; + font-weight: 400; + letter-spacing: -0.065em; + color: var(--rust); +} +.hero-copy > p:not(.eyebrow) { + font-size: 16px; + line-height: 1.7; + max-width: 455px; + margin: 30px 0; +} +.hero-actions { + display: flex; + align-items: center; + gap: 25px; +} +.workflow-hero { + position: relative; + min-width: 0; + margin-top: 20px; + padding: 24px 22px 15px; + background: #e9ece3; + border: 1px solid #d5dbcf; + border-radius: 4px; + box-shadow: 0 16px 45px -35px #34452d66; +} +.diagram-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--muted); + font-size: 8px; +} +.diagram-mode { + display: flex; + justify-content: center; + margin: 32px auto 0; + width: max-content; + border: 1px solid #cbd2c2; + border-radius: 4px; + padding: 3px; + background: #e0e5d9; +} +.diagram-mode button, +.segmented button { + font-size: 11px; + padding: 7px 13px; + min-height: 36px; + border-radius: 2px; +} +.diagram-mode button[aria-pressed="true"] { + background: var(--white); + box-shadow: 0 1px 5px #00000010; +} +.orbit { + height: 340px; + position: relative; + isolation: isolate; +} +.orbit-lines { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + fill: none; + stroke: #aab9a1; + stroke-width: 1; + transition: opacity 0.5s; +} +.orbit-lines circle { + stroke-dasharray: 2 7; + opacity: 0.6; +} +.orbit-node { + position: absolute; + padding: 10px 12px; + background: #f8f9f3; + border: 1px solid #cfd7c7; + border-radius: 3px; + font-size: 10px; + display: flex; + align-items: center; + gap: 9px; + z-index: 2; + min-height: 40px; + box-shadow: 0 3px 5px #27332104; + transition: + transform 0.5s, + background 0.5s; +} +.node-symbol { + width: 18px; + height: 18px; + display: grid; + place-items: center; + border: 1px solid #d5dace; + border-radius: 2px; + color: #64715f; +} +.node-0 { + left: 5%; + top: 12%; +} +.node-1 { + right: 1%; + top: 13%; +} +.node-2 { + left: 0; + top: 46%; +} +.node-3 { + right: -2%; + top: 46%; +} +.node-4 { + left: 5%; + bottom: 8%; +} +.node-5 { + right: 3%; + bottom: 8%; +} +.orbit-core { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 132px; + height: 128px; + background: var(--dark); + color: var(--paper); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border: 1px solid #455a4c; + border-radius: 5px; + box-shadow: 0 10px 18px #1d302820; + z-index: 1; +} +.core-monogram { + font-size: 37px; + line-height: 1.3; + letter-spacing: -3px; +} +.orbit-core strong { + font-size: 11px; + letter-spacing: 0.02em; +} +.orbit-core > span:last-child { + font-size: 7px; + color: #c0cbbd; + margin-top: 5px; +} +.diagram-caption { + border-top: 1px solid #cfd7c7; + padding-top: 17px; + font-size: 10px; + display: flex; + align-items: center; + gap: 9px; +} +.status-dot { + width: 5px; + height: 5px; + background: #69815a; + display: inline-block; + border-radius: 100%; + flex: none; +} +.diagram-note { + font-size: 8px; + letter-spacing: 0.035em; + margin: 8px 0 0; + color: #72786e; + text-transform: none; +} +.fragmented .orbit-lines { + stroke-dasharray: 5 9; + opacity: 0.3; +} +.fragmented .orbit-node { + background: #f2eee7; +} +.fragmented .node-0, +.fragmented .node-4 { + transform: rotate(-4deg); +} +.fragmented .node-1, +.fragmented .node-3 { + transform: rotate(4deg); +} +.fragmented .orbit-core { + background: #74766c; +} +.proof-line { + border-block: 1px solid var(--line); + padding-block: 22px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + font-size: 12px; +} +.proof-label { + font-size: 8px; + color: var(--muted); +} +.proof-line > a { + display: flex; + align-items: center; + gap: 13px; +} +.proof-end { + font-size: 10px; + color: var(--muted); +} +.problem { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14%; +} +.problem h2 { + font-size: clamp(38px, 4.3vw, 65px); +} +.problem-right { + padding-top: 50px; +} +.body-large { + font-size: 27px; + line-height: 1.35; + letter-spacing: -0.025em; + color: var(--ink); +} +.problem-right > p:not(.body-large) { + font-size: 14px; + max-width: 460px; +} +.fragment-chain { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; + padding: 16px 0 26px; + font-size: 11px; + color: var(--muted); +} +.fragment-chain span { + padding: 5px 7px; + border: 1px solid var(--line); + background: #efeee8; +} +.fragment-chain b { + font-weight: 400; + color: #919886; +} +.organized-line { + display: flex; + gap: 18px; + border-top: 1px solid var(--line); + padding-block: 20px; + font-size: 15px; +} +.organized-line > span { + color: var(--rust); +} +.organized-line strong { + font-weight: 500; +} +.section-heading { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 40px; + margin-bottom: 54px; +} +.section-heading > p { + max-width: 320px; + font-size: 14px; + margin: 0 0 4px; +} +.section-heading h2 { + max-width: 800px; +} +.module-explorer { + display: grid; + grid-template-columns: 1fr 1.65fr; + gap: 70px; +} +.module-list button { + border-bottom: 1px solid var(--line); + width: 100%; + text-align: left; + display: flex; + align-items: center; + gap: 16px; + padding: 15px 7px; + font-size: 14px; + min-height: 55px; +} +.module-list button > span:last-child { + margin-left: auto; + font-size: 19px; +} +.module-number { + font-size: 9px; + color: var(--muted); +} +.module-list button[aria-pressed="true"] { + color: var(--rust); + border-color: var(--rust); +} +.module-list button:hover { + background: #eeece5; +} +.module-detail { + min-width: 0; + padding: 25px 32px; + background: #e9ece3; + border: 1px solid #d6dccf; + display: flex; + flex-direction: column; +} +.module-stage { + min-height: 275px; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + padding: 28px 10px; +} +.module-start { + background: var(--white); + font-size: 12px; + padding: 12px 20px; + border: 1px solid #ced5c7; + border-radius: 3px; +} +.module-start > span { + color: var(--rust); + margin-right: 15px; +} +.module-connector { + height: 27px; + border-left: 1px solid #a6b39e; +} +.module-system { + background: var(--dark); + color: var(--paper); + padding: 19px 22px; + min-width: 80%; + border-radius: 4px; +} +.mini-label { + font-size: 9px; +} +.module-system > .mini-label { + color: #b7c6ac; + font-size: 7px; + display: block; +} +.module-system > strong { + font-size: 16px; + font-weight: 400; + display: block; + margin: 8px 0 20px; +} +.module-branch { + display: flex; + gap: 13px; + border-top: 1px solid #617060; + padding-top: 17px; +} +.module-branch > span { + border: 1px solid #617060; + font-size: 10px; + padding: 8px 10px; + flex: 1; +} +.module-copy { + margin-top: auto; +} +.module-copy h3 { + font-size: 28px; +} +.module-copy p { + font-size: 13px; + line-height: 1.6; + margin: 14px 0 0; +} +.module-copy .small-copy { + font-size: 11px; + border-top: 1px solid #cbd3c3; + padding-top: 14px; +} +.project-feature { + background: var(--dark); + color: var(--paper); + padding: 58px 0 75px; +} +.project-feature p { + color: #bac5b9; +} +.feature-top { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 8px; + border-bottom: 1px solid #506052; + padding-bottom: 26px; + margin-bottom: 50px; +} +.feature-top .eyebrow { + margin: 0; + color: #c2ccbb; + font-size: 9px; +} +.feature-top .eyebrow > span { + background: #b6c79e; +} +.feature-grid { + display: grid; + grid-template-columns: 1fr 1.2fr; + gap: 100px; + align-items: center; +} +.feature-grid h2 { + font-size: clamp(42px, 4.5vw, 67px); +} +.feature-grid .feature-company { + color: var(--paper); + font-size: 17px; + margin: 30px 0 10px; +} +.feature-grid p { + font-size: 13px; + max-width: 370px; +} +.feature-grid .text-link { + border-color: #62735f; +} +.feature-grid .text-link:hover { + color: #d1ddc2; +} +.customer-visual { + background: #dae2d0; + color: var(--dark); + padding: 35px; + border: 1px solid #9baa8e; + min-height: 330px; + display: flex; + flex-direction: column; + justify-content: space-between; +} +.customer-visual:hover { + background: #e1e8d9; + color: var(--dark); + transform: translateY(-3px); +} +.customer-visual-top { + display: flex; + align-items: center; + gap: 19px; + font-size: 13px; + line-height: 1.45; + font-weight: 600; + letter-spacing: 0.09em; +} +.customer-emblem { + font-family: Georgia, serif; + font-size: 78px; + line-height: 1; + letter-spacing: -5px; +} +.customer-emblem > span { + font-family: Arial, sans-serif; + font-size: 35px; + vertical-align: super; + margin-left: -10px; +} +.customer-flow { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; +} +.customer-flow > span { + border: 1px solid #a9b69d; + padding: 9px 12px; +} +.customer-flow i { + height: 1px; + background: #a9b69d; + flex: 1; +} +.customer-visual-bottom { + display: flex; + align-items: center; + justify-content: space-between; + border-top: 1px solid #b8c3ad; + padding-top: 18px; + font-size: 10px; +} +.customer-visual-bottom > span:first-child { + display: flex; + gap: 9px; + align-items: center; +} +.customer-visual-bottom > span:last-child { + font-size: 22px; +} +.transformation { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 90px; +} +.transformation h3 { + font-size: 40px; +} +.transformation p:not(.eyebrow) { + font-size: 14px; + max-width: 330px; + margin-top: 22px; +} +.segmented { + display: inline-flex; + border: 1px solid var(--line); + padding: 4px; + margin-top: 5px; +} +.segmented button[aria-pressed="true"] { + background: var(--ink); + color: var(--paper); +} +.transform-steps { + list-style: none; + margin: 0; + padding: 0; +} +.transform-steps li { + display: flex; + align-items: center; + gap: 20px; + border-bottom: 1px solid var(--line); + padding: 18px 0; + font-size: 13px; + min-height: 61px; +} +.transform-steps li > span { + font-size: 9px; + color: var(--muted); +} +.transform-steps.connected li > span { + color: var(--rust); +} +.process-nav { + display: flex; + border-bottom: 1px solid var(--line); + gap: 0; +} +.process-nav button { + flex: 1; + text-align: left; + font-size: 14px; + padding: 14px 0 20px; + border-bottom: 2px solid transparent; +} +.process-nav button > span { + display: block; + font-size: 9px; + color: var(--muted); + margin-bottom: 14px; +} +.process-nav button[aria-pressed="true"] { + border-color: var(--rust); + color: var(--rust); +} +.process-detail { + display: grid; + grid-template-columns: 1fr 1.35fr; + gap: 60px; + align-items: center; + min-height: 280px; + padding: 40px 0; +} +.process-big { + font-family: Georgia, serif; + font-size: 180px; + letter-spacing: -0.09em; + color: #d5dacd; + line-height: 1; +} +.process-detail > div { + max-width: 610px; +} +.process-detail h3 { + font-size: 34px; +} +.process-detail p:not(.eyebrow) { + font-size: 14px; + margin: 20px 0 0; +} +.process-detail .eyebrow { + font-size: 9px; + margin: 0 0 20px; +} +.founder-section { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 100px; + align-items: center; +} +.founder-type { + font-size: clamp(50px, 5.4vw, 87px); + line-height: 0.99; + letter-spacing: -0.06em; + position: relative; + padding-block: 50px; +} +.founder-type > span { + color: var(--rust); + font-family: Georgia, serif; + font-style: italic; +} +.founder-type small { + display: block; + font-family: var(--font); + font-size: 8px; + letter-spacing: 0.12em; + line-height: 1.5; + margin-top: 40px; + color: var(--muted); +} +.founder-section h2 { + font-size: 37px; + margin-bottom: 25px; +} +.founder-section p:not(.eyebrow) { + font-size: 14px; +} +.article-row { + display: grid; + grid-template-columns: 180px 1fr 115px; + align-items: center; + gap: 30px; + padding: 30px 0; + border-top: 1px solid var(--line); +} +.article-row h3 { + font-size: 24px; + line-height: 1.3; + max-width: 700px; + letter-spacing: -0.025em; +} +.article-row > span:last-child { + font-size: 10px; + display: flex; + justify-content: space-between; + align-items: center; + color: var(--muted); +} +.article-row b { + font-size: 20px; + font-weight: 400; +} +.cta { + padding-block: 86px; + background: #e5e9de; +} +.cta-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 50px; +} +.cta h2 { + font-size: clamp(36px, 4vw, 60px); +} +.cta p:not(.eyebrow) { + font-size: 13px; + margin: 25px 0 0; +} +.footer { + padding-block: 55px 25px; +} +.footer-top { + display: flex; + justify-content: space-between; + gap: 40px; + padding-bottom: 50px; +} +.footer-brand { + font-size: 24px; + font-weight: 600; + letter-spacing: -0.06em; +} +.footer-top p { + font-size: 12px; + margin: 12px 0 0; +} +.footer nav { + display: flex; + gap: 25px; + align-items: flex-start; + font-size: 11px; + padding-top: 10px; +} +.footer-bottom { + border-top: 1px solid var(--line); + padding-top: 23px; + display: flex; + justify-content: space-between; + gap: 20px; + font-size: 9px; + color: var(--muted); +} +.footer-bottom > div { + display: flex; + gap: 25px; +} +.page-intro { + padding-block: 95px 75px; +} +.page-intro h1 { + max-width: 1100px; +} +.intro-copy { + max-width: 610px; + margin-top: 30px; + font-size: 18px; +} +.intro-copy p { + margin-bottom: 24px; +} +.page-intro + .section { + padding-top: 40px; +} +.intro-copy .button { + margin-top: 12px; +} +.service-row { + display: grid; + grid-template-columns: 50px 1fr 1fr; + gap: 35px; + padding: 40px 0; + border-top: 1px solid var(--line); +} +.service-row > span { + font-size: 11px; + color: var(--rust); +} +.service-row h2 { + font-size: 30px; + max-width: 400px; + line-height: 1.22; +} +.service-row h3 { + font-size: 21px; + letter-spacing: -0.02em; + line-height: 1.3; + margin: 0 0 16px; +} +.service-row p { + font-size: 14px; +} +.service-tags { + display: flex; + gap: 18px; + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} +.prose-grid { + display: grid; + grid-template-columns: 1fr 1.4fr; + gap: 100px; +} +.prose-grid h2 { + font-size: 40px; +} +.prose-grid p { + max-width: 690px; +} +.principle-row { + display: grid; + grid-template-columns: 1fr 1.3fr; + gap: 80px; + padding: 30px 0; + border-top: 1px solid var(--line); +} +.principle-row h3 { + font-size: 25px; +} +.principle-row p { + font-size: 14px; + margin: 0; +} +.case-heading .intro-copy { + max-width: 710px; +} +.case-meta { + display: grid; + grid-template-columns: repeat(3, 1fr); + border-block: 1px solid var(--line); + padding-block: 25px; + gap: 30px; + margin-top: 10px; +} +.case-meta p { + margin: 0; + font-size: 14px; + color: var(--ink); +} +.case-meta span { + display: block; + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--muted); + margin-bottom: 8px; +} +.case-lead { + font-size: 22px; + line-height: 1.5; + color: var(--ink); +} +.care-system { + background: #e4e9dc; + padding: 30px; + border: 1px solid #cbd5bf; +} +.care-system-body { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 70px; + margin: 35px 0; +} +.care-path button { + display: flex; + text-align: left; + align-items: center; + gap: 15px; + width: 100%; + border-top: 1px solid #c1ccb4; + min-height: 58px; + padding: 12px 8px; + font-size: 13px; +} +.care-path button > span:first-child { + font-size: 9px; +} +.care-path button > span:last-child { + margin-left: auto; +} +.care-path button[aria-pressed="true"] { + background: var(--dark); + color: var(--paper); + border-color: var(--dark); +} +.care-detail { + padding: 25px; + display: flex; + flex-direction: column; + justify-content: center; + border-left: 1px solid #c1ccb4; +} +.care-detail-index { + font-size: 64px; + line-height: 1; + color: #869779; + font-family: Georgia, serif; + margin-bottom: 28px; +} +.care-detail h3 { + font-size: 30px; +} +.care-detail p { + font-size: 14px; + margin: 20px 0; +} +.care-detail .mini-label { + margin-top: 20px; + color: var(--muted); + font-size: 8px; +} +.case-caption { + font-size: 12px; + border-left: 2px solid var(--rust); + padding-left: 20px; + margin-top: 35px; + max-width: 660px; +} +.article-list .article-row { + grid-template-columns: 160px 1fr 120px; + padding-block: 38px; +} +.article-summary p { + font-size: 14px; + max-width: 650px; + margin: 14px 0 0; +} +.article-body { + max-width: 730px; + margin: 0 auto; + padding: 10px 0 90px; +} +.article-body h2 { + font-size: 30px; + margin: 45px 0 20px; + letter-spacing: -0.025em; +} +.article-body p { + font-size: 17px; + line-height: 1.85; +} +.article-back { + border-top: 1px solid var(--line); + padding-top: 30px; + margin-top: 45px; +} +.article-meta { + display: flex; + gap: 25px; + font-size: 11px; + color: var(--muted); + margin-top: 30px; +} +.contact-layout { + display: grid; + grid-template-columns: 0.7fr 1.3fr; + gap: 100px; + padding-bottom: 100px; +} +.contact-aside { + font-size: 14px; + align-self: start; + position: sticky; + top: 50px; +} +.contact-aside h2 { + font-size: 26px; + margin-bottom: 20px; +} +.contact-aside ol { + padding-left: 20px; + color: var(--muted); + font-size: 13px; +} +.contact-aside li { + padding: 10px 0; +} +.project-form { + min-width: 0; +} +.form-section { + border: 0; + border-top: 1px solid var(--line); + margin: 0 0 35px; + padding: 22px 0 0; + min-width: 0; +} +.form-section legend { + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; + padding-right: 20px; +} +.field-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 23px; +} +.field { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 23px; +} +.field label { + font-size: 12px; + font-weight: 600; + line-height: 1.6; +} +.optional { + font-size: 10px; + font-weight: 400; + color: var(--muted); + margin-left: 7px; +} +.field input, +.field textarea, +.field select { + width: 100%; + min-width: 0; + border: 1px solid #c5cbbd; + border-radius: 2px; + padding: 12px 13px; + background: var(--white); + font-size: 14px; + color: var(--ink); + min-height: 48px; +} +.field textarea { + resize: vertical; + min-height: 112px; +} +.field [aria-invalid="true"] { + border-color: #a34228; +} +.field-error { + color: #923521; + font-size: 12px; +} +.form-status { + background: #e9eddf; + border-left: 3px solid #567147; + padding: 20px; + margin: 20px 0; + font-size: 14px; +} +.form-status.error { + background: #f1e5db; + border-color: var(--rust); +} +.form-status h2 { + font-size: 28px; + margin-bottom: 15px; +} +.form-status p { + margin-bottom: 0; +} +.form-privacy { + font-size: 11px; + max-width: 520px; + margin: 20px 0; +} +.form-privacy a { + text-decoration: underline; +} +.honeypot { + position: absolute; + left: -10000px; + width: 1px; + height: 1px; + overflow: hidden; +} +.loading { + min-height: 60vh; + padding-block: 90px; + font-size: 13px; +} +.loading-line { + display: block; + width: 90px; + height: 2px; + background: var(--rust); + margin-bottom: 25px; + animation: loading 1s ease-in-out infinite alternate; +} +@keyframes loading { + to { + width: 180px; + opacity: 0.4; + } +} +.legal { + max-width: 750px; + margin: 0 auto; + padding-bottom: 100px; +} +.legal h2 { + font-size: 27px; + margin: 35px 0 18px; +} +.legal p { + font-size: 15px; +} +@media (min-width: 1700px) { + .hero { + gap: 100px; + padding-block: 115px 100px; + } + .hero h1 { + font-size: 88px; + } + .workflow-hero { + padding: 30px; + } + .orbit { + height: 405px; + } + .orbit-node { + font-size: 12px; + padding: 13px 18px; + } + .orbit-core { + width: 150px; + height: 146px; + } +} +@media (max-width: 1100px) { + .container { + width: calc(100% - 72px); + } + .hero { + gap: 25px; + padding-block: 75px; + } + .hero h1 { + font-size: 58px; + } + .hero .eyebrow { + font-size: 8px; + } + .workflow-hero { + padding: 20px 14px; + } + .orbit { + height: 320px; + } + .orbit-node { + padding: 8px; + font-size: 9px; + gap: 4px; + } + .node-symbol { + display: none; + } + .orbit-core { + width: 113px; + height: 119px; + } + .hero-actions { + gap: 15px; + } + .hero-actions .text-link { + font-size: 11px; + gap: 13px; + } + .section { + padding-block: 85px; + } + .module-explorer { + gap: 35px; + } + .feature-grid { + gap: 55px; + } + .customer-visual { + padding: 25px; + } + .customer-flow > span { + padding: 8px; + } + .founder-section { + gap: 55px; + } + .navigation { + gap: 25px; + } + .prose-grid { + gap: 65px; + } + .contact-layout { + gap: 55px; + } + .article-body, + .legal { + width: calc(100% - 100px); + } + .proof-end { + display: none; + } +} +@media (max-width: 850px) { + .hero { + grid-template-columns: 1fr 1fr; + gap: 25px; + } + .hero h1 { + font-size: 47px; + } + .hero-copy > p:not(.eyebrow) { + font-size: 14px; + } + .hero-actions { + align-items: flex-start; + flex-direction: column; + gap: 10px; + } + .orbit { + height: 300px; + } + .orbit-core { + width: 98px; + height: 110px; + } + .orbit-core > span:last-child { + font-size: 6px; + } + .orbit-node { + font-size: 8px; + padding: 6px; + min-height: 32px; + } + .diagram-heading { + font-size: 7px; + } + .navigation { + gap: 20px; + } + .navigation .button-small { + gap: 15px; + } + .problem { + gap: 60px; + } + .module-explorer { + grid-template-columns: 1fr 1.35fr; + gap: 30px; + } + .module-detail { + padding: 20px; + } + .module-list button { + font-size: 12px; + } + .module-stage { + padding-inline: 0; + } + .module-system { + min-width: 100%; + padding: 15px; + } + .module-copy h3 { + font-size: 25px; + } + .feature-grid { + gap: 40px; + } + .feature-grid h2 { + font-size: 45px; + } + .customer-flow { + font-size: 9px; + } + .customer-visual { + min-height: 310px; + padding: 20px; + } + .customer-visual-top { + font-size: 10px; + } + .transformation { + gap: 45px; + } + .transformation h3 { + font-size: 34px; + } + .founder-section { + gap: 40px; + } + .founder-section h2 { + font-size: 31px; + } + .article-row { + grid-template-columns: 130px 1fr 85px; + gap: 20px; + } + .article-row h3 { + font-size: 21px; + } + .footer nav { + gap: 15px; + } + .footer-top { + gap: 20px; + } + .cta-inner { + gap: 30px; + } + .cta h2 { + font-size: 38px; + } + .service-row { + gap: 20px; + grid-template-columns: 30px 1fr 1fr; + } + .service-row h2 { + font-size: 25px; + } + .care-system-body { + gap: 30px; + } + .contact-layout { + grid-template-columns: 0.65fr 1.35fr; + gap: 35px; + } +} +@media (max-width: 700px) { + .container { + width: calc(100% - 40px); + } + .header-inner { + min-height: 76px; + } + .wordmark { + font-size: 23px; + } + .menu-toggle { + display: flex; + gap: 25px; + align-items: center; + font-size: 12px; + min-height: 44px; + padding: 8px 3px; + } + .navigation { + display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--paper); + border-bottom: 1px solid var(--line); + padding: 20px; + flex-direction: column; + align-items: stretch; + gap: 0; + box-shadow: 0 20px 25px #20272210; + } + .navigation.is-open { + display: flex; + } + .navigation > a { + padding: 13px 10px; + font-size: 16px; + } + .navigation .button { + margin-top: 15px; + } + .hero { + grid-template-columns: 1fr; + padding: 54px 0 40px; + gap: 30px; + } + .hero .eyebrow { + font-size: 8px; + margin-bottom: 25px; + } + .hero h1 { + font-size: clamp(44px, 8.5vw, 65px); + max-width: 520px; + line-height: 1.06; + } + .hero .desktop-break { + display: none; + } + .hero-copy > p:not(.eyebrow) { + font-size: 15px; + max-width: 480px; + margin: 25px 0; + } + .hero-actions { + flex-direction: row; + align-items: center; + gap: 22px; + } + .hero-actions .text-link { + font-size: 12px; + gap: 16px; + } + .button { + padding: 14px 18px; + gap: 22px; + min-height: 50px; + font-size: 12px; + } + .workflow-hero { + margin-top: 8px; + padding: 22px 20px 16px; + } + .diagram-heading { + font-size: 8px; + } + .diagram-mode { + margin-top: 25px; + } + .orbit { + height: 315px; + } + .orbit-node { + padding: 9px 10px; + font-size: 10px; + min-height: 40px; + gap: 6px; + } + .node-symbol { + display: grid; + } + .orbit-core { + width: 122px; + height: 125px; + } + .orbit-core > span:last-child { + font-size: 7px; + } + .node-0 { + left: 6%; + } + .node-1 { + right: 3%; + } + .node-2 { + left: 0; + } + .node-3 { + right: 0; + } + .node-4 { + left: 7%; + } + .node-5 { + right: 5%; + } + .proof-line { + flex-direction: column; + align-items: flex-start; + gap: 12px; + padding-block: 23px; + } + .proof-label { + font-size: 8px; + } + .proof-line > a { + font-size: 12px; + gap: 10px; + } + .section { + padding-block: 65px; + } + .problem { + grid-template-columns: 1fr; + gap: 25px; + } + .problem h2 { + font-size: clamp(34px, 12vw, 44px); + } + .problem h2 br { + display: none; + } + .problem-right { + padding: 0; + } + .body-large { + font-size: 24px; + } + .body-large br { + display: none; + } + .problem-right > p:not(.body-large) { + max-width: none; + font-size: 14px; + } + .eyebrow { + font-size: 9px; + margin-bottom: 22px; + } + .fragment-chain { + padding-top: 0; + } + .section-heading { + display: block; + margin-bottom: 32px; + } + .section-heading h2 { + font-size: 37px; + } + .section-heading > p { + margin: 23px 0 0; + max-width: none; + } + .section-heading > .text-link { + margin-top: 20px; + } + .module-explorer { + grid-template-columns: 1fr; + gap: 25px; + } + .module-list { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0 15px; + } + .module-list button { + font-size: 11px; + padding: 12px 0; + gap: 6px; + min-height: 50px; + line-height: 1.4; + } + .module-number { + display: none; + } + .module-list button > span:last-child { + font-size: 14px; + } + .module-detail { + padding: 22px; + } + .module-stage { + min-height: 245px; + padding: 22px 0; + } + .module-system { + min-width: 75%; + } + .module-system > strong { + font-size: 17px; + } + .module-copy h3 { + font-size: 28px; + } + .project-feature { + padding: 35px 0 45px; + } + .feature-top { + margin-bottom: 32px; + gap: 15px; + } + .feature-top > span { + font-size: 7px; + } + .feature-grid { + grid-template-columns: 1fr; + gap: 35px; + } + .feature-grid h2 { + font-size: 49px; + } + .feature-grid .feature-company { + margin-top: 24px; + } + .feature-grid p { + max-width: 480px; + font-size: 14px; + } + .customer-visual { + min-height: 300px; + padding: 27px; + } + .customer-visual-top { + font-size: 12px; + } + .customer-flow { + font-size: 11px; + } + .customer-flow > span { + padding: 9px 14px; + } + .transformation { + grid-template-columns: 1fr; + gap: 25px; + } + .transformation h3 { + font-size: 37px; + } + .transformation h3 br { + display: none; + } + .transformation p:not(.eyebrow) { + max-width: none; + } + .process-nav { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px 18px; + border: 0; + } + .process-nav button { + border-bottom: 1px solid var(--line); + font-size: 12px; + padding: 10px 0; + } + .process-nav button > span { + margin-bottom: 9px; + } + .process-detail { + grid-template-columns: 70px 1fr; + gap: 25px; + min-height: 300px; + padding: 35px 0; + } + .process-big { + font-size: 83px; + align-self: start; + margin-top: 36px; + } + .process-detail h3 { + font-size: 28px; + } + .process-detail .eyebrow { + font-size: 8px; + } + .founder-section { + grid-template-columns: 1fr; + gap: 38px; + } + .founder-type { + padding: 0; + font-size: 67px; + } + .founder-type small { + margin-top: 25px; + } + .founder-section h2 { + font-size: 35px; + } + .article-row, + .article-list .article-row { + grid-template-columns: 1fr 95px; + gap: 14px; + padding: 26px 0; + } + .article-row > .mini-label { + grid-column: 1/-1; + font-size: 8px; + } + .article-row h3 { + font-size: 22px; + } + .article-row > span:last-child { + align-self: end; + } + .cta { + padding-block: 55px; + } + .cta-inner { + display: block; + } + .cta h2 { + font-size: 38px; + } + .cta p:not(.eyebrow) { + font-size: 13px; + } + .cta .button { + margin-top: 28px; + } + .desktop-break { + display: none; + } + .footer { + padding-block: 40px 24px; + } + .footer-top { + display: block; + padding-bottom: 30px; + } + .footer nav { + display: flex; + flex-wrap: wrap; + gap: 15px 24px; + margin-top: 25px; + padding: 0; + } + .footer nav a { + min-height: 35px; + display: flex; + align-items: center; + } + .footer-bottom { + display: block; + font-size: 10px; + } + .footer-bottom > div { + flex-wrap: wrap; + justify-content: space-between; + margin-top: 20px; + gap: 16px; + } + .page-intro { + padding-block: 55px 45px; + } + .page-intro h1 { + font-size: 46px; + } + .intro-copy { + font-size: 16px; + } + .service-row { + grid-template-columns: 25px 1fr; + gap: 12px 17px; + padding: 28px 0; + } + .service-row > div { + grid-column: 2; + } + .service-row h2 { + font-size: 26px; + margin-bottom: 8px; + max-width: none; + } + .service-row h3 { + font-size: 18px; + } + .service-row p { + font-size: 13px; + } + .service-tags { + flex-wrap: wrap; + font-size: 8px; + gap: 10px; + } + .prose-grid { + grid-template-columns: 1fr; + gap: 30px; + } + .prose-grid h2 { + font-size: 35px; + } + .prose-grid p { + font-size: 15px; + } + .principle-row { + grid-template-columns: 1fr; + gap: 18px; + } + .principle-row h3 { + font-size: 26px; + } + .case-meta { + grid-template-columns: 1fr; + gap: 20px; + } + .case-meta > div { + display: grid; + grid-template-columns: 95px 1fr; + gap: 15px; + } + .case-meta span { + margin: 0; + } + .case-meta p { + font-size: 13px; + } + .care-system { + padding: 20px; + } + .care-system-body { + grid-template-columns: 1fr; + gap: 25px; + margin: 25px 0; + } + .care-detail { + border-left: 0; + border-top: 1px solid #c1ccb4; + padding: 22px 0 0; + min-height: 270px; + } + .care-detail-index { + font-size: 50px; + margin-bottom: 18px; + } + .care-detail h3 { + font-size: 27px; + } + .care-detail p { + font-size: 13px; + margin: 17px 0; + } + .care-path button { + font-size: 12px; + min-height: 52px; + } + .care-system .diagram-heading { + font-size: 7px; + } + .contact-layout { + grid-template-columns: 1fr; + gap: 30px; + padding-bottom: 65px; + } + .contact-aside { + position: static; + } + .contact-aside ol { + margin-bottom: 0; + } + .contact-aside h2 { + font-size: 25px; + } + .field-grid { + gap: 0; + grid-template-columns: 1fr; + } + .field input, + .field textarea, + .field select { + font-size: 16px; + } + .article-body, + .legal { + width: calc(100% - 40px); + padding-bottom: 65px; + } + .article-body h2 { + font-size: 28px; + margin-top: 30px; + } + .article-body p { + font-size: 16px; + } + .article-meta { + font-size: 10px; + gap: 17px; + flex-wrap: wrap; + } + .article-summary p { + font-size: 13px; + } + .article-list .article-row { + grid-template-columns: 1fr; + } + .article-list .article-row > span:last-child { + max-width: 125px; + gap: 20px; + } + .article-list .article-row h3 { + font-size: 27px; + } + .article-row > .article-summary { + grid-column: 1/-1; + } +} +@media (max-width: 370px) { + .container { + width: calc(100% - 32px); + } + .hero h1 { + font-size: 43px; + } + .hero-actions { + gap: 13px; + } + .hero-actions .text-link { + font-size: 10px; + gap: 10px; + } + .hero-actions .button { + font-size: 11px; + padding: 13px 14px; + gap: 18px; + } + .orbit { + height: 295px; + } + .orbit-node { + font-size: 8px; + padding: 7px; + } + .node-symbol { + display: none; + } + .orbit-core { + width: 104px; + height: 115px; + } + .diagram-heading { + font-size: 7px; + } + .diagram-caption { + font-size: 9px; + } + .module-list { + gap: 0 9px; + } + .module-list button { + font-size: 10px; + } + .module-detail { + padding: 17px; + } + .module-system { + width: 100%; + padding: 15px; + } + .customer-visual { + padding: 20px; + } + .customer-flow > span { + padding: 8px; + font-size: 10px; + } + .feature-top { + font-size: 7px; + } + .feature-top .eyebrow { + font-size: 8px; + } + .customer-visual-bottom { + font-size: 9px; + } + .process-detail { + grid-template-columns: 50px 1fr; + gap: 20px; + } + .process-big { + font-size: 65px; + } + .page-intro h1 { + font-size: 40px; + } + .cta h2 { + font-size: 35px; + } + .founder-type { + font-size: 60px; + } + .article-row { + grid-template-columns: 1fr; + } + .article-row > span:last-child { + max-width: 110px; + gap: 20px; + } } -.dark { - color-scheme: dark; +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + } } -body { - @apply bg-slate-50 text-slate-900 antialiased dark:bg-slate-900 dark:text-slate-100; +.article-summary h2 { + font-size: 30px; + line-height: 1.2; + letter-spacing: -0.035em; +} +@media (max-width: 700px) { + .article-summary h2 { + font-size: 27px; + } } -@media print { - .no-print { - display: none !important; +/* Keep the footer outside the initial viewport while streamed content arrives. */ +#main { + min-height: calc(100svh - 94px); +} +:root { + --muted: #596152; +} +.diagram-note { + color: #596352; + font-size: 10px; +} +.process-big { + color: #7c8873; +} +.care-detail-index { + color: #657753; +} +.customer-visual-top { + font-size: 27px; + letter-spacing: -0.035em; + line-height: 1.12; + font-weight: 500; +} +.diagram-heading { + font-size: 9px; +} +.orbit-core > span:last-child { + font-size: 8px; +} +@media (max-width: 700px) { + #main { + min-height: calc(100svh - 76px); + } + .workflow-hero .orbit { + height: 360px; + } + .workflow-hero .orbit-core { + width: 106px; + height: 110px; + } + .workflow-hero .orbit-node { + max-width: 88px; + min-height: 46px; + white-space: normal; + text-align: center; + justify-content: center; + font-size: 10px; + line-height: 1.3; + padding: 8px; + } + .workflow-hero .node-symbol { + display: none; + } + .workflow-hero .node-2 { + top: 46%; + left: -5px; + } + .workflow-hero .node-3 { + top: 46%; + right: -5px; + } + .workflow-hero .node-0, + .workflow-hero .node-4 { + left: 4%; + } + .workflow-hero .node-1, + .workflow-hero .node-5 { + right: 4%; + } + .workflow-hero .orbit-core > span:last-child { + max-width: 80px; + text-align: center; + font-size: 8px; + } + .customer-visual-top { + font-size: 28px; + } + .diagram-heading { + font-size: 8px; + } +} +@media (max-width: 370px) { + .workflow-hero { + padding-inline: 14px; + } + .workflow-hero .orbit-core { + width: 88px; + height: 110px; + } + .workflow-hero .orbit-node { + max-width: 73px; + font-size: 9px; + } + .workflow-hero .node-2 { + left: -5px; } - body { - @apply bg-white; + .workflow-hero .node-3 { + right: -5px; } } diff --git a/apps/web/src/app/icon.svg b/apps/web/src/app/icon.svg new file mode 100644 index 0000000..51ae9cc --- /dev/null +++ b/apps/web/src/app/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/app/insights/[slug]/page.tsx b/apps/web/src/app/insights/[slug]/page.tsx new file mode 100644 index 0000000..6f97107 --- /dev/null +++ b/apps/web/src/app/insights/[slug]/page.tsx @@ -0,0 +1,42 @@ +import { notFound } from "next/navigation"; +import { PageIntro, Action, ProjectCTA } from "@/components/primitives"; +import { articles } from "@/lib/content"; +import { pageMetadata } from "@/lib/site"; +export function generateStaticParams() { + return articles.map((a) => ({ slug: a.slug })); +} +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const a = articles.find((a) => a.slug === slug); + return a ? pageMetadata(a.title, a.description, "/insights/" + a.slug) : {}; +} +export default async function Article({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const a = articles.find((a) => a.slug === slug); + if (!a) notFound(); + return ( + <> + +

{a.description}

+
+ GhimTech / Studio notes + {a.readTime} +
+
+
+ {a.sections.map(([title, copy]) => ( +
+

{title}

+

{copy}

+
+ ))} +
+ + All Insights + +
+
+ + + ); +} diff --git a/apps/web/src/app/insights/page.tsx b/apps/web/src/app/insights/page.tsx new file mode 100644 index 0000000..5371091 --- /dev/null +++ b/apps/web/src/app/insights/page.tsx @@ -0,0 +1,37 @@ +import Link from "next/link"; +import { PageIntro, ProjectCTA } from "@/components/primitives"; +import { articles } from "@/lib/content"; +import { pageMetadata } from "@/lib/site"; +export const metadata = pageMetadata( + "Insights", + "Practical notes on business systems, workflow automation, custom software, and the decisions behind better operations.", + "/insights", +); +export default function Insights() { + return ( + <> + +

+ Practical notes on the decisions that make business software useful. Start with the + workflow, question the assumptions, and build with a clear purpose. +

+
+
+ {articles.map((a) => ( + + {a.category} +
+

{a.title}

+

{a.description}

+
+ + {a.readTime} + + + + ))} +
+ + + ); +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 926e365..eba8327 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,20 +1,54 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; +import { Header } from "@/components/site-shell"; +import { Footer } from "@/components/primitives"; +import { siteUrl } from "@/lib/site"; import "./globals.css"; - export const metadata: Metadata = { - title: { - default: "GhimTech Tax", - template: "%s · GhimTech Tax", - }, + metadataBase: new URL(siteUrl), + title: { default: "GhimTech | Software built around your business", template: "%s | GhimTech" }, description: - "GhimTech Tax — private professional tax preparation and e-file platform. Built by GhimTech.", - robots: { index: false, follow: false }, + "GhimTech builds custom business software, CRM systems, and automated workflows around how your business operates. Founded by Durga Ghimeray.", + alternates: { canonical: "/" }, + openGraph: { type: "website", siteName: "GhimTech", locale: "en_US" }, + twitter: { card: "summary_large_image" }, }; - +export const viewport: Viewport = { themeColor: "#f5f3ed", width: "device-width", initialScale: 1 }; export default function RootLayout({ children }: { children: React.ReactNode }) { + const schema = { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "Organization", + "@id": siteUrl + "/#organization", + name: "GhimTech", + url: siteUrl, + description: "Custom business software and operational systems.", + founder: { "@id": siteUrl + "/#founder" }, + }, + { + "@type": "Person", + "@id": siteUrl + "/#founder", + name: "Durga Ghimeray", + jobTitle: "Founder", + url: siteUrl + "/about", + sameAs: ["https://github.com/durga710"], + }, + ], + }; return ( - - {children} + + + + Skip to content + +
+
{children}
+