Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,24 @@ S3_FORCE_PATH_STYLE=true
# Host port of the local S3 gateway.
S3_PORT=8333

# --- Environment -------------------------------------------------------------------------------
# local | showcase | production. Only `local` accepts the committed local-default auth secret.
APP_ENV=local

# --- Authentication (Better Auth) ---------------------------------------------------------------
# Signs sessions and cookies; at least 32 characters. Generate a real one: `openssl rand -base64 32`.
BETTER_AUTH_SECRET=local-dev-only-secret-change-me-0123456789
# Public base URL of the web app (cookies, redirects, trusted origin).
BETTER_AUTH_URL=http://localhost:3000
# Client IP for the login rate limit. Set to what YOUR reverse proxy writes and list the proxy
# addresses; without trusted proxies only a single-value header is trusted. If the web container is
# reachable without a proxy, clients can forge this header – put a proxy in front (see operations.md).
AUTH_IP_HEADERS=x-forwarded-for
AUTH_TRUSTED_PROXIES=

# Demo seed only (`pnpm seed:demo`): password of the synthetic demo accounts, local use only.
SEED_PASSWORD=demo-password-local-only

# --- Web ---------------------------------------------------------------------------------------
# Host port of the web container.
WEB_PORT=3000
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ This file records what changes **in the product** – process and session state
## [Unreleased]

### Added
- Invite-only login (e-mail + password): admins invite staff into their own company and hand over
an invitation link; sign-up without a valid invitation link creates no account. Roles `admin` and `clerk` per company.
- Tenant isolation: every company-owned table has forced row-level security; data access runs
inside `withTenant()`.
- Login rate limit (stored in the database) and `pnpm seed:demo` with two synthetic companies.
- Runnable local stack: `docker compose up` starts PostgreSQL 17, SeaweedFS (S3), a one-shot `setup`
step (migrations + private bucket), the web app and a no-op worker.
- `GET /api/health` reports database and storage status (200 / 503, no connection details).
Expand Down
3 changes: 3 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ services:
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-local-access-key}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-local-secret-key}
S3_FORCE_PATH_STYLE: "true"
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-local-dev-only-secret-change-me-0123456789}
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
APP_ENV: ${APP_ENV:-local}
depends_on:
postgres:
condition: service_healthy
Expand Down
14 changes: 8 additions & 6 deletions docs/technical/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ approve them, and exports each approved request exactly once to an ERP (mock in
It is a TypeScript modular monolith (`web` + `worker` from one codebase) on PostgreSQL, plus the
AI service. Decisions and rationale: [ADR-0001](../decisions/ADR-0001-pilot-architecture.md).

**Current state (2026-09-22): app skeleton (#3)** – runnable stack, health endpoint, database roles and
schema `app`, module skeletons with enforced boundaries. Status per module below (`skeleton` = public
**Current state (2026-09-23): app skeleton (#3) + identity/tenancy (#4)** – runnable stack, health
endpoint, database roles, invite-only login, companies, `withTenant()` with forced RLS, module
skeletons with enforced boundaries. Tables: [data-model.md](data-model.md). Status per module below (`skeleton` = public
`index.ts` only).

## Modules
Expand All @@ -25,19 +26,19 @@ Every new file belongs to one of these modules – otherwise add the module here
| `intake` | `src/features/intake/` | upload, duplicate fingerprint, creates request + documents | authenticated UI/route | confidential + personal | session, tenant context, size/type limits | skeleton |
| `documents` | `src/features/documents/` | document records, storage references, hashes | internal | confidential | tenant context | skeleton |
| `extraction` | `src/features/extraction/` | AI-service client, persists runs/fields/evidence | internal | confidential + personal | tenant context, contract validation | skeleton |
| `requests` | `src/features/requests/` | request aggregate, status machine | internal | confidential | tenant context | skeleton |
| `requests` | `src/features/requests/` | request aggregate, status machine | internal | confidential | tenant context | partial: `app.requests` + repository (status machine: #7) |
| `review` | `src/features/review/` | review UI, corrections, approve/reject | authenticated UI | confidential + personal | session, role check, audit | skeleton |
| `export` | `src/features/export/` | ERP port + REST adapter, idempotency | outbound HTTP | confidential | idempotency key, unique export, timeout | skeleton |
| `erp-mock` | `src/features/erp-mock/` | simulated ERP REST API | route behind flag | synthetic | disabled unless `ERP_MOCK_ENABLED` | skeleton |
| `identity` | `src/features/identity/` | Better Auth, users, companies, roles | public login route | personal (staff) | rate limit, invite-only | skeleton |
| `tenancy` | `src/features/tenancy/` | `withTenant()`, RLS policies | internal | – | forced RLS, `app_rw` without BYPASSRLS | skeleton |
| `identity` | `src/features/identity/` | Better Auth, users, companies, roles | public login route | personal (staff) | rate limit, invite-only | partial: Better Auth (invite-only, organization + admin plugins), `authorize()`, invite, seed |
| `tenancy` | `src/features/tenancy/` | `withTenant()`, RLS policies | internal | – | forced RLS, `app_rw` without BYPASSRLS | built: `withTenant()`, forced RLS on `app.*` |
| `audit` | `src/features/audit/` | append-only audit events | internal | personal (staff) | INSERT/SELECT only | skeleton |
| `jobs` | `src/features/jobs/`, entrypoint `src/worker.ts` | pg-boss, job handlers, `drain()`, worker entrypoint | internal | IDs only | transactional enqueue | skeleton (no-op worker) |
| `storage` | `src/features/storage/` | `BlobStore` port + S3 adapter | internal | confidential | private bucket, access via app routes | partial: S3 adapter, bucket setup, health ping |
| `observability` | `src/features/observability/` | logger, health, request-list ops data | `/api/health` | IDs only | no PII in logs | partial: health aggregation (database, storage) |
| `db` | `src/db/`, deploy step `src/setup.ts` | Drizzle schema, migrations, DB roles | internal | – | migrations as owner role | built: roles check, schema `app`, default grants for `app_rw` |
| `config` | `src/config/` | typed runtime configuration, validated at start (zod) | internal | secrets (in memory only) | errors name variables, never values | built |
| `app` | `src/app/` | Next.js routes and pages; composition root `src/app/_server/` (pool, storage client) | `/`, `/api/health` | – | calls module APIs only (dependency-cruiser) | skeleton: placeholder page, health route |
| `app` | `src/app/` | Next.js routes and pages; composition root `src/app/_server/` (pool, storage client) | `/`, `/login`, `/signup`, `/invite`, `/api/auth/*`, `/api/health` | – | calls module APIs only (dependency-cruiser) | partial: login, sign-up, invite, home |
| AI service | `services/ai/` | docling parsing, extraction, grounding, evals | internal HTTP | confidential + personal (transient) | bearer token, stateless, no DB/storage access | planned |
| Contracts | `contracts/` | OpenAPI: AI service, ERP export | – | – | contract tests | planned |

Expand All @@ -49,6 +50,7 @@ Deliberately accepted risks – without an entry here a deviation counts as a de
|---|---|---|---|
| No RLS on the `auth` and `pgboss` schemas | Not company-owned business data; reachable only by server code (ADR-0001 D7) | Fluory | 2026-12-31 (review at M3) |
| Showcase without unattended retries (Vercel Hobby cron once/day) | Showcase only; production runs a worker (D2) | Fluory | when a production-like demo is needed |
| Better Auth admin plugin mounted without any holder of its admin role | ADR-0001 D6 names the plugin; nobody holds `platform-admin`, so `/api/auth/admin/*` rejects every caller (tested); user management runs through `identity` | Fluory | with #30 (decide: keep for ban/deactivate or remove) |
| Gemini API free tier for local development | Synthetic data only; never showcase or customer data (D8) | Fluory | when a Vertex development budget exists |

## Data flow
Expand Down
54 changes: 54 additions & 0 deletions docs/technical/data-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Data model – RequestFlow

> Living document: whoever adds or changes a table updates this file **in the same PR**.
> Source of truth: `src/db/schema/` + `src/db/migrations/`. Classification per the `datenschutz` add-on:
> public / internal / confidential / personal.

## Schemas

| Schema | Owner | Runtime access (`app_rw`) | Tenant isolation |
|---|---|---|---|
| `app` | `app_owner` | DML via default privileges, no CREATE | every table: `company_id` + RLS **enabled and forced**, policy `<table>_tenant_isolation` |
| `auth` | `app_owner` | DML on all tables, no CREATE | none – Better Auth data, server code only (exceptions register) |
| `drizzle` | `app_owner` | none | migration journal |

Tenant policy (all `app` tables): `company_id = nullif(current_setting('app.company_id', true), '')::uuid`
for `USING` and `WITH CHECK`. `withTenant()` sets `app.company_id` transaction-locally; without it a
query sees zero rows and every write fails.

## Tables

### `app.requests` – request aggregate (#4, extended by #5/#7)

| Column | Type | Notes | Class |
|---|---|---|---|
| `id` | uuid PK | `gen_random_uuid()` | internal |
| `company_id` | uuid FK → `auth.organization.id` | tenant key, `ON DELETE RESTRICT` | internal |
| `status` | text | `NEW · PROCESSING · REVIEW · APPROVED · EXPORTED · REJECTED · ERROR` (check constraint) | internal |
| `created_at` | timestamptz | | internal |

Purpose: one quote request per row. Retention: open question for the customer (ADR-0001 open points).

### `auth.*` – Better Auth 1.7.5 (generated with the Better Auth CLI, timestamps with time zone)

| Table | Content | Class | Purpose |
|---|---|---|---|
| `user` | name, e-mail, global role (`user`), ban fields | personal (staff) | login identity |
| `account` | password hash (credential provider) | confidential | authentication |
| `session` | token, expiry, IP, user agent, `active_organization_id` | personal (staff) | session; carries the active company |
| `verification` | verification tokens | confidential | e-mail verification (unused in the pilot) |
| `organization` | company name, slug | internal | company = tenant |
| `member` | user ↔ company, company role `admin`/`clerk`; unique `user_id` (one company per user) | internal | membership + role |
| `invitation` | e-mail, company, role, status, expiry, inviter; the random `id` is the sign-up token (link) | personal (staff) | invite-only sign-up |
| `rate_limit` | key (IP + path), counter | personal (IP) | built-in rate limit, database storage |

A system user `system@requestflow.invalid` (no password account, no membership) is the inviter of each
company's first admin; it can never obtain a session.

## Relations

```text
auth.organization 1─n auth.member n─1 auth.user 1─n auth.session / auth.account
auth.organization 1─n auth.invitation
auth.organization 1─n app.requests (company_id)
```
14 changes: 14 additions & 0 deletions docs/technical/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ migration aborts if `app_owner`/`app_rw` are missing or could bypass RLS – fix
customer) an operator creates `app_owner` and `app_rw` once with the same statements (passwords from
the secret manager), before the first `setup` run; the first migration refuses to run otherwise.

## Login rate limit and client IP

Better Auth limits `/api/auth/*` per client IP (5 sign-ins/sign-ups per minute, counters in
`auth.rate_limit`). The IP comes from `AUTH_IP_HEADERS`; that header is only trustworthy when a
reverse proxy sets it and clients cannot reach the web container directly. Any deployment beyond the
local machine puts a proxy in front and lists it in `AUTH_TRUSTED_PROXIES`. A per-account limit is a
follow-up (not in the pilot).

## Invitations and account recovery

Invite-only: an admin creates an invitation on `/invite` and hands over the link
(`/signup?invitation=<id>`, 7 days valid); e-mail delivery is not part of the pilot. There is no
self-service password reset yet – recovery is an operator task (delete the user row, invite again).

## Frequent failures

| Symptom | Cause | Action |
Expand Down
4 changes: 2 additions & 2 deletions drizzle.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { defineConfig } from "drizzle-kit";
// drizzle-kit runs as the owner role; the app itself connects as `app_rw` (ADR-0001 D7).
export default defineConfig({
dialect: "postgresql",
schema: "./src/db/schema.ts",
schema: "./src/db/schema/index.ts",
out: "./src/db/migrations",
schemaFilter: ["app"],
schemaFilter: ["app", "auth"],
migrations: { schema: "drizzle" },
dbCredentials: { url: process.env.MIGRATION_DATABASE_URL ?? "" },
});
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@
"verify:changed": "bash scripts/verify-changed.sh",
"verify": "pnpm lint && pnpm typecheck && pnpm test && pnpm test:integration && pnpm depcruise && pnpm build && pnpm audit --audit-level high",
"verify:full": "pnpm verify",
"setup:deploy": "tsx src/setup.ts"
"setup:deploy": "tsx src/setup.ts",
"seed:demo": "tsx src/seed.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "3.1138.0",
"@better-auth/drizzle-adapter": "1.7.5",
"better-auth": "1.7.5",
"drizzle-orm": "0.45.3",
"next": "16.3.6",
"pg": "8.23.0",
Expand Down
Loading
Loading