Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
21da04a
chore(deps): add better-auth 1.7.5 and the drizzle adapter
claude Sep 22, 2026
cb5f334
chore: merge claude/chore-app-skeleton-3
claude Sep 22, 2026
1cf8f2f
feat(identity): company roles and authorize() (test-first), organizat…
claude Sep 22, 2026
e4d28d2
feat(db): auth schema (Better Auth) and app.requests with tenant policy
claude Sep 23, 2026
c67654e
feat(db): migration for auth tables and app.requests
claude Sep 23, 2026
0364a58
wip(identity,tenancy): withTenant, auth wiring and integration tests
claude Sep 23, 2026
0958d25
feat(identity,tenancy): invite-only Better Auth, withTenant, forced R…
claude Sep 23, 2026
4992b90
feat(app): auth route, login/sign-up/invite pages, demo seed
claude Sep 23, 2026
799551e
chore: merge claude/chore-app-skeleton-3
claude Sep 23, 2026
6a3490a
docs: data model with classification, architecture map and changelog …
claude Sep 23, 2026
5dfea7d
chore: merge claude/chore-app-skeleton-3
claude Sep 23, 2026
fce4d6e
chore(deps): add pg-boss 12.33.6
claude Sep 23, 2026
10623ec
feat(db,jobs): documents and audit_events tables, intake columns, pg-…
claude Sep 23, 2026
e0afd29
feat(db): migration 0003 intake
claude Sep 23, 2026
40d3c8f
feat(intake): upload validation, fingerprint, atomic submit, upload a…
claude Sep 23, 2026
81ee674
test(intake): atomic submit with injected failure, duplicates, append…
claude Sep 23, 2026
a8a2808
fix(identity): address the fresh security review of PR #31
claude Sep 23, 2026
20aeb0f
chore: merge claude/feat-identity-tenancy-4
claude Sep 23, 2026
c75a955
test(intake): upload/download routes – tenant 404, anonymous 401, typ…
claude Sep 23, 2026
bff8c68
feat(app): requests list, upload form, request detail with downloads;…
claude Sep 23, 2026
02715e0
fix(config): explicit APP_ENV; only local may use the committed auth …
claude Sep 23, 2026
c0da064
chore: merge claude/feat-identity-tenancy-4
claude Sep 23, 2026
66ab13c
wip(intake): review fixes – pg-boss into src/db, body bounds, OOXML c…
claude Sep 23, 2026
2a0b891
fix(intake): address the fresh security review of PR #32
claude Sep 23, 2026
a162aa9
test: rate-limit assertion matches Better Auth's expanded IPv6 key
claude Sep 23, 2026
d805a9a
refactor(db): pg-boss pool factory in its own file, covered by the no…
claude Sep 23, 2026
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
9 changes: 8 additions & 1 deletion .dependency-cruiser.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,14 @@ module.exports = {
severity: "error",
comment: "Feature modules never open connections or run migrations: createDatabase()/runMigrations() belong to the composition roots (src/app/_server, src/*.ts entrypoints). Types and table definitions (src/db/schema) are fine.",
from: { path: "^src/features/" },
to: { path: "^src/db/(index|client|migrate)\\.ts$", dependencyTypesNot: ["type-only"] },
to: { path: "^src/db/(index|client|migrate|job-queue-client)\\.ts$", dependencyTypesNot: ["type-only"] },
},
{
name: "pg-boss-client-only-in-db",
severity: "error",
comment: "pg-boss opens its own pool: construct it only in src/db (job-queue.ts); features use injected JobSender types.",
from: { path: "^src/", pathNot: "^src/db/" },
to: { path: "(^|/)node_modules/pg-boss(/|$)", dependencyTypesNot: ["type-only"] },
},
{
name: "not-to-unresolvable",
Expand Down
25 changes: 25 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,31 @@ 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

# --- Upload limits (intake) ---------------------------------------------------------------------
# Maximum size per file in bytes (default 20 MiB) and files per request (default 10).
UPLOAD_MAX_FILE_BYTES=20971520
UPLOAD_MAX_FILES=10
# Cap of one whole upload request (all files + form overhead); requests without Content-Length are refused.
UPLOAD_MAX_REQUEST_BYTES=41943040

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

### Added
- Upload of a quote request (`/requests`): .eml, .msg, .pdf, .xlsx, .docx up to a configured size;
originals stored privately, download only for the own company. Request, documents, audit entry and
the processing job are created in one step; exact duplicates are flagged and linked.
- 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
25 changes: 25 additions & 0 deletions docs/technical/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,28 @@
(showcase epic #19).
- Checks are added additively (queue backlog and AI service follow with #28); clients must ignore
unknown check names.

## `POST /api/requests` (session required)

Multipart form, field `files` (1–`UPLOAD_MAX_FILES` files, each ≤ `UPLOAD_MAX_FILE_BYTES`, whole request
≤ `UPLOAD_MAX_REQUEST_BYTES`, `Content-Length` required). Allowed: `.eml .msg .pdf .xlsx .docx`, checked
by extension **and** content: signature for all; for `.xlsx/.docx` also the package structure (no
macros, no foreign ZIPs, declared size/entry limits). `.msg` is checked by its OLE signature only.

| Status | Body |
|---|---|
| 201 | `{"requestId": uuid, "possibleDuplicate": bool, "duplicateOfId": uuid \| null}` |
| 401 / 403 | `{"error":{"title":"…"}}` – not signed in / role |
| 411 | no numeric `Content-Length` (the body is bounded before it is read) |
| 413 | declared body larger than `UPLOAD_MAX_REQUEST_BYTES` |
| 422 | `{"error":{"title":"Dateityp nicht erlaubt: …"}}` – user-facing reason |
| 500 | generic message; details only as IDs in the log |

Request (NEW), documents, audit event and the `request-process` job commit in one transaction.
Duplicates: same `Message-ID` (read from `.eml` only – `.msg` Message-ID extraction is a follow-up)
or the same set of file hashes within the company; checks are serialised per company.

## `GET /api/documents/:id` (session required)

Streams the original as `attachment` with `x-content-type-options: nosniff` and `cache-control: private,
no-store`. A document of another company answers 404 like a missing one; no session → 401.
26 changes: 15 additions & 11 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): #3 skeleton, #4 identity/tenancy, #5 intake** – runnable stack, health
endpoint, invite-only login, companies, `withTenant()` with forced RLS, upload with atomic enqueue
and duplicate flags, module boundaries enforced. Tables: [data-model.md](data-model.md). Status per module below (`skeleton` = public
`index.ts` only).

## Modules
Expand All @@ -22,22 +23,22 @@ Every new file belongs to one of these modules – otherwise add the module here

| Module | Location | Task | Exposure | Data class | Protection | Status |
|---|---|---|---|---|---|---|
| `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 |
| `intake` | `src/features/intake/` | upload, duplicate fingerprint, creates request + documents | authenticated UI/route | confidential + personal | session, tenant context, size/type limits | built: upload validation (extension + signature, size), fingerprint, atomic submit |
| `documents` | `src/features/documents/` | document records, storage references, hashes | internal | confidential | tenant context | built: records, SHA-256, storage keys |
| `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 |
| `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 |
| `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 | partial: `recordAudit()` (append-only enforced by grants) |
| `jobs` | `src/features/jobs/`, entrypoint `src/worker.ts` | pg-boss, job handlers, `drain()`, worker entrypoint | internal | IDs only | transactional enqueue | partial: queues + transactional enqueue; worker no-op until #7 |
| `storage` | `src/features/storage/` | `BlobStore` port + S3 adapter | internal | confidential | private bucket, access via app routes | built: S3 adapter (put/get/delete, bucket setup, 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`, `/requests`, `/api/requests`, `/api/documents/:id`, `/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,9 @@ 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) |
| Upload endpoint without a per-user rate limit | Authenticated staff only; body bounded by `Content-Length` + `UPLOAD_MAX_REQUEST_BYTES` before reading | Fluory | before any public deployment (#19) |
| `.msg` uploads checked by OLE signature only | Structure check of Outlook messages needs a CFB parser; files are served only as attachments with `nosniff` and parsed later by the stateless AI service | Fluory | with #23 (MSG parsing) |
| 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
87 changes: 87 additions & 0 deletions docs/technical/data-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# 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) |
| `pgboss` | `app_owner` (deploy step installs schema + queues) | DML only | none – job queue, IDs 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 |
| `source` | text | `upload` (mailbox later) | internal |
| `created_by` | uuid | uploading user | personal (staff) |
| `subject` | text | mail subject or first file name, max 300 chars | confidential |
| `message_id` | text | `Message-ID` of an uploaded mail – duplicate key | personal |
| `fingerprint` | text | SHA-256 over the sorted file hashes – duplicate key | internal |
| `possible_duplicate` / `duplicate_of_id` | boolean / uuid | exact duplicate within the company; composite FK `(duplicate_of_id, company_id)` | internal |

Unique `(id, company_id)` so child tables can pin the company with composite foreign keys (FK checks
bypass RLS). Purpose: one quote request per row. Retention: open question for the customer (ADR-0001 open points).

### `app.documents` – originals of a request (#5)

| Column | Type | Notes | Class |
|---|---|---|---|
| `id`, `company_id`, `request_id` | uuid | composite FK `(request_id, company_id)` → `requests` | internal |
| `filename`, `content_type`, `kind` | text | kind `eml · msg · pdf · xlsx · docx` | confidential |
| `size_bytes`, `sha256` | bigint, text | SHA-256 of the raw bytes (integrity, duplicates) | internal |
| `storage_key` | text | `{companyId}/{requestId}/{documentId}` in the private bucket | internal |

The bytes (confidential + personal) live only in object storage; served via `GET /api/documents/:id`.

### `app.audit_events` – append-only business audit (#5, ADR-0001 D10)

| Column | Type | Notes | Class |
|---|---|---|---|
| `company_id`, `entity_type`, `entity_id` | | what changed | internal |
| `actor_user_id` | uuid | who | personal (staff) |
| `action`, `data` | text, jsonb | e.g. `request.uploaded`; old/new values later – no document content | internal/confidential |

`app_rw` has INSERT and SELECT only (UPDATE/DELETE/TRUNCATE revoked). Written in the same transaction
as the change.

### `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)
app.requests 1─n app.documents (request_id, company_id)
app.requests 0─1 app.requests (duplicate_of_id, company_id)
app.* 1─n app.audit_events (entity_type, entity_id – no FK, append-only)
```
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 ?? "" },
});
Loading
Loading