diff --git a/.env.example b/.env.example
index 5e27cb4..953f61c 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 20f4a7d..857c24c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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).
diff --git a/compose.yaml b/compose.yaml
index bbe54fb..fd11f01 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -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
diff --git a/docs/technical/architecture.md b/docs/technical/architecture.md
index 53f74f4..be7e922 100644
--- a/docs/technical/architecture.md
+++ b/docs/technical/architecture.md
@@ -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
@@ -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 |
@@ -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
diff --git a/docs/technical/data-model.md b/docs/technical/data-model.md
new file mode 100644
index 0000000..be6da22
--- /dev/null
+++ b/docs/technical/data-model.md
@@ -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 `
_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)
+```
diff --git a/docs/technical/operations.md b/docs/technical/operations.md
index 83834ec..9e5888b 100644
--- a/docs/technical/operations.md
+++ b/docs/technical/operations.md
@@ -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=`, 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 |
diff --git a/drizzle.config.ts b/drizzle.config.ts
index f46881d..cf0bfdf 100644
--- a/drizzle.config.ts
+++ b/drizzle.config.ts
@@ -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 ?? "" },
});
diff --git a/package.json b/package.json
index 1750a8a..b449d67 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7d01764..4f678a7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,9 +11,15 @@ importers:
'@aws-sdk/client-s3':
specifier: 3.1138.0
version: 3.1138.0
+ '@better-auth/drizzle-adapter':
+ specifier: 1.7.5
+ version: 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0))
+ better-auth:
+ specifier: 1.7.5
+ version: 1.7.5(drizzle-kit@0.31.11)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0))(next@16.3.6(@babel/core@7.29.7)(@types/node@24.13.6)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@5.0.1(@types/node@24.13.6)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15)))
drizzle-orm:
specifier: 0.45.3
- version: 0.45.3(@types/pg@8.23.1)(pg@8.23.0)
+ version: 0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0)
next:
specifier: 16.3.6
version: 16.3.6(@babel/core@7.29.7)(@types/node@24.13.6)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
@@ -205,6 +211,85 @@ packages:
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
engines: {node: '>=6.9.0'}
+ '@better-auth/core@1.7.5':
+ resolution: {integrity: sha512-kVlSu4H8OKQfjg4b/Zj5MOaospt83N0JbX38wsDzE58Yw95jzovFkU3pxzB1eUFYc4mkuhUMZD8iT1gpUjNMcQ==}
+ peerDependencies:
+ '@better-auth/utils': 0.4.2
+ '@better-fetch/fetch': 1.3.2
+ '@opentelemetry/api': ^1.9.0
+ better-call: 1.4.0
+ jose: ^6.1.0
+ kysely: ^0.28.5 || ^0.29.0
+ nanostores: ^1.0.1
+ peerDependenciesMeta:
+ '@opentelemetry/api':
+ optional: true
+
+ '@better-auth/drizzle-adapter@1.7.5':
+ resolution: {integrity: sha512-9SM7v1735SoaedRDcDbHc5ULgXEd2vUlEJkvRHpMF2Q9qf59TRh1b5A9hryyecyi56bm/0CNUDU3nY0uVWj5/Q==}
+ peerDependencies:
+ '@better-auth/core': ^1.7.5
+ '@better-auth/utils': 0.4.2
+ drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0
+ peerDependenciesMeta:
+ drizzle-orm:
+ optional: true
+
+ '@better-auth/kysely-adapter@1.7.5':
+ resolution: {integrity: sha512-1wE5gvnjW+c1i4GrLtL9HLn3s0Xrq4YneCDan1NO1dpz0mEguLFNlzfnUGykTFrDwvFh2X5rkIbA8ALCj6WzXQ==}
+ peerDependencies:
+ '@better-auth/core': ^1.7.5
+ '@better-auth/utils': 0.4.2
+ kysely: ^0.28.17 || ^0.29.0
+ peerDependenciesMeta:
+ kysely:
+ optional: true
+
+ '@better-auth/memory-adapter@1.7.5':
+ resolution: {integrity: sha512-YDmnfR9zOXbn5SNYYwfHBPuc19hg1d1C1vUXb+Hm8Q91pTsstFNX5OTlZbo7f28q06FpogAEfGGCN/2QV39cig==}
+ peerDependencies:
+ '@better-auth/core': ^1.7.5
+ '@better-auth/utils': 0.4.2
+
+ '@better-auth/mongo-adapter@1.7.5':
+ resolution: {integrity: sha512-Yq0LfF0VlA9Kfjcjp/v43MSsChv7vKd1ct0Mt15px4zlqaCPIGfPbjw9YNM6jTo0fGpqLR0n15PllSWmLLRp9g==}
+ peerDependencies:
+ '@better-auth/core': ^1.7.5
+ '@better-auth/utils': 0.4.2
+ mongodb: ^6.0.0 || ^7.0.0
+ peerDependenciesMeta:
+ mongodb:
+ optional: true
+
+ '@better-auth/prisma-adapter@1.7.5':
+ resolution: {integrity: sha512-QfW6HS9vK0FMcbI/GsQLdplICxOz0EPzYWGONZT4ovL3cSItd4YH/09USbPPVl+VUQCdznAQIk+n+NKvHdmSag==}
+ peerDependencies:
+ '@better-auth/core': ^1.7.5
+ '@better-auth/utils': 0.4.2
+ '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
+ prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
+ peerDependenciesMeta:
+ '@prisma/client':
+ optional: true
+ prisma:
+ optional: true
+
+ '@better-auth/telemetry@1.7.5':
+ resolution: {integrity: sha512-e/REPqMy9Em+gC6G0xWBikiMLRuy532Er7jqdoNkPBa65FbywgWcm1cZbgdW5CnHsrM3OLZsmKn14yeGoDISeg==}
+ peerDependencies:
+ '@better-auth/core': ^1.7.5
+ '@better-auth/utils': 0.4.2
+ '@better-fetch/fetch': 1.3.2
+
+ '@better-auth/utils@0.4.2':
+ resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==}
+
+ '@better-auth/utils@0.5.0':
+ resolution: {integrity: sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==}
+
+ '@better-fetch/fetch@1.3.2':
+ resolution: {integrity: sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==}
+
'@drizzle-team/brocli@0.10.2':
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
@@ -959,6 +1044,14 @@ packages:
cpu: [x64]
os: [win32]
+ '@noble/ciphers@2.4.0':
+ resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==}
+ engines: {node: '>= 20.19.0'}
+
+ '@noble/hashes@2.4.0':
+ resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==}
+ engines: {node: '>= 20.19.0'}
+
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -975,6 +1068,10 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
+ '@opentelemetry/semantic-conventions@1.43.0':
+ resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==}
+ engines: {node: '>=14'}
+
'@oxc-project/types@0.150.0':
resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==}
@@ -1098,6 +1195,9 @@ packages:
resolution: {integrity: sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==}
engines: {node: '>=18.0.0'}
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
'@swc/helpers@0.5.23':
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
@@ -1418,6 +1518,73 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ better-auth@1.7.5:
+ resolution: {integrity: sha512-aKE0Zt2EPTpFvmq4/oATNyG/mAfc6JUqWkW9pzGlrVnzUb0lso7GJ9BPxD6JPhLqYV1a9zOAb0uAz1Q5fm+eHA==}
+ peerDependencies:
+ '@lynx-js/react': '*'
+ '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
+ '@sveltejs/kit': ^2.0.0
+ '@tanstack/react-start': ^1.0.0
+ '@tanstack/solid-start': ^1.0.0
+ drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1'
+ drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0
+ mongodb: ^6.0.0 || ^7.0.0
+ mysql2: ^3.0.0
+ next: ^14.0.0 || ^15.0.0 || ^16.0.0
+ pg: ^8.0.0
+ prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ solid-js: ^1.0.0
+ svelte: ^4.0.0 || ^5.0.0
+ vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0
+ vue: ^3.0.0
+ peerDependenciesMeta:
+ '@lynx-js/react':
+ optional: true
+ '@prisma/client':
+ optional: true
+ '@sveltejs/kit':
+ optional: true
+ '@tanstack/react-start':
+ optional: true
+ '@tanstack/solid-start':
+ optional: true
+ drizzle-kit:
+ optional: true
+ drizzle-orm:
+ optional: true
+ mongodb:
+ optional: true
+ mysql2:
+ optional: true
+ next:
+ optional: true
+ pg:
+ optional: true
+ prisma:
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+ solid-js:
+ optional: true
+ svelte:
+ optional: true
+ vitest:
+ optional: true
+ vue:
+ optional: true
+
+ better-call@1.4.0:
+ resolution: {integrity: sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==}
+ peerDependencies:
+ zod: ^4.0.0
+ peerDependenciesMeta:
+ zod:
+ optional: true
+
bowser@2.14.1:
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
@@ -1537,6 +1704,9 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
+ defu@6.1.7:
+ resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
+
dependency-cruiser@18.4.0:
resolution: {integrity: sha512-LLBYQ2XYmOCMG+liUWI2ReRYnnFXIbnzvCGHTohXAViSkawXr3T35fF/FV2cxpmB661pfkJ3ZXn95jhcEQ9GqA==}
engines: {node: ^22||^24||>=26}
@@ -2160,6 +2330,9 @@ packages:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'}
+ jose@6.2.12:
+ resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -2201,6 +2374,10 @@ packages:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'}
+ kysely@0.29.6:
+ resolution: {integrity: sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==}
+ engines: {node: '>=22.0.0'}
+
language-subtag-registry@0.3.23:
resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
@@ -2329,6 +2506,10 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ nanostores@1.5.3:
+ resolution: {integrity: sha512-rQLB6eV4f2AW/n3L0JmwCROpaisYy9EDEADvEFSd1C/qG8hB6O5TPlh9A791JRbJr4CnMQBzptDcvD9OR1+6WA==}
+ engines: {node: ^20.0.0 || >=22.0.0}
+
napi-postinstall@0.3.4:
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -2578,6 +2759,9 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
+ rou3@0.9.2:
+ resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==}
+
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -2608,6 +2792,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ set-cookie-parser@3.1.2:
+ resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==}
+
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -3241,6 +3428,63 @@ snapshots:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
+ '@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3)':
+ dependencies:
+ '@better-auth/utils': 0.4.2
+ '@better-fetch/fetch': 1.3.2
+ '@opentelemetry/semantic-conventions': 1.43.0
+ '@standard-schema/spec': 1.1.0
+ better-call: 1.4.0(zod@4.6.5)
+ jose: 6.2.12
+ kysely: 0.29.6
+ nanostores: 1.5.3
+ zod: 4.6.5
+
+ '@better-auth/drizzle-adapter@1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0))':
+ dependencies:
+ '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3)
+ '@better-auth/utils': 0.4.2
+ optionalDependencies:
+ drizzle-orm: 0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0)
+
+ '@better-auth/kysely-adapter@1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(kysely@0.29.6)':
+ dependencies:
+ '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3)
+ '@better-auth/utils': 0.4.2
+ optionalDependencies:
+ kysely: 0.29.6
+
+ '@better-auth/memory-adapter@1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)':
+ dependencies:
+ '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3)
+ '@better-auth/utils': 0.4.2
+
+ '@better-auth/mongo-adapter@1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)':
+ dependencies:
+ '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3)
+ '@better-auth/utils': 0.4.2
+
+ '@better-auth/prisma-adapter@1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)':
+ dependencies:
+ '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3)
+ '@better-auth/utils': 0.4.2
+
+ '@better-auth/telemetry@1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)':
+ dependencies:
+ '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3)
+ '@better-auth/utils': 0.4.2
+ '@better-fetch/fetch': 1.3.2
+
+ '@better-auth/utils@0.4.2':
+ dependencies:
+ '@noble/hashes': 2.4.0
+
+ '@better-auth/utils@0.5.0':
+ dependencies:
+ '@noble/hashes': 2.4.0
+
+ '@better-fetch/fetch@1.3.2': {}
+
'@drizzle-team/brocli@0.10.2': {}
'@emnapi/core@1.10.0':
@@ -3729,6 +3973,10 @@ snapshots:
'@next/swc-win32-x64-msvc@16.3.6':
optional: true
+ '@noble/ciphers@2.4.0': {}
+
+ '@noble/hashes@2.4.0': {}
+
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -3743,6 +3991,8 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
+ '@opentelemetry/semantic-conventions@1.43.0': {}
+
'@oxc-project/types@0.150.0': {}
'@rolldown/binding-android-arm-eabi@1.2.9':
@@ -3827,6 +4077,8 @@ snapshots:
dependencies:
tslib: 2.8.1
+ '@standard-schema/spec@1.1.0': {}
+
'@swc/helpers@0.5.23':
dependencies:
tslib: 2.8.1
@@ -4157,6 +4409,45 @@ snapshots:
baseline-browser-mapping@2.11.25: {}
+ better-auth@1.7.5(drizzle-kit@0.31.11)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0))(next@16.3.6(@babel/core@7.29.7)(@types/node@24.13.6)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@5.0.1(@types/node@24.13.6)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15))):
+ dependencies:
+ '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3)
+ '@better-auth/drizzle-adapter': 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0))
+ '@better-auth/kysely-adapter': 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(kysely@0.29.6)
+ '@better-auth/memory-adapter': 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)
+ '@better-auth/mongo-adapter': 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)
+ '@better-auth/prisma-adapter': 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)
+ '@better-auth/telemetry': 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)
+ '@better-auth/utils': 0.4.2
+ '@better-fetch/fetch': 1.3.2
+ '@noble/ciphers': 2.4.0
+ '@noble/hashes': 2.4.0
+ better-call: 1.4.0(zod@4.6.5)
+ defu: 6.1.7
+ jose: 6.2.12
+ kysely: 0.29.6
+ nanostores: 1.5.3
+ zod: 4.6.5
+ optionalDependencies:
+ drizzle-kit: 0.31.11
+ drizzle-orm: 0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0)
+ next: 16.3.6(@babel/core@7.29.7)(@types/node@24.13.6)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ pg: 8.23.0
+ react: 19.3.0
+ react-dom: 19.3.0(react@19.3.0)
+ vitest: 5.0.1(@types/node@24.13.6)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15))
+ transitivePeerDependencies:
+ - '@opentelemetry/api'
+
+ better-call@1.4.0(zod@4.6.5):
+ dependencies:
+ '@better-auth/utils': 0.5.0
+ '@better-fetch/fetch': 1.3.2
+ rou3: 0.9.2
+ set-cookie-parser: 3.1.2
+ optionalDependencies:
+ zod: 4.6.5
+
bowser@2.14.1: {}
brace-expansion@1.1.21:
@@ -4274,6 +4565,8 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
+ defu@6.1.7: {}
+
dependency-cruiser@18.4.0:
dependencies:
acorn: 8.18.0
@@ -4308,9 +4601,10 @@ snapshots:
esbuild: 0.25.12
tsx: 4.23.15
- drizzle-orm@0.45.3(@types/pg@8.23.1)(pg@8.23.0):
+ drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0):
optionalDependencies:
'@types/pg': 8.23.1
+ kysely: 0.29.6
pg: 8.23.0
dunder-proto@1.0.1:
@@ -5046,6 +5340,8 @@ snapshots:
has-symbols: 1.1.0
set-function-name: 2.0.2
+ jose@6.2.12: {}
+
js-tokens@4.0.0: {}
js-yaml@4.3.2:
@@ -5079,6 +5375,8 @@ snapshots:
kleur@3.0.3: {}
+ kysely@0.29.6: {}
+
language-subtag-registry@0.3.23: {}
language-tags@1.0.9:
@@ -5180,6 +5478,8 @@ snapshots:
nanoid@3.3.19: {}
+ nanostores@1.5.3: {}
+
napi-postinstall@0.3.4: {}
natural-compare@1.4.0: {}
@@ -5456,6 +5756,8 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.2.9
'@rolldown/binding-win32-x64-msvc': 1.2.9
+ rou3@0.9.2: {}
+
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -5489,6 +5791,8 @@ snapshots:
semver@7.8.5: {}
+ set-cookie-parser@3.1.2: {}
+
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
diff --git a/src/app/_components/auth-form.tsx b/src/app/_components/auth-form.tsx
new file mode 100644
index 0000000..06df4af
--- /dev/null
+++ b/src/app/_components/auth-form.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useState, type FormEvent } from "react";
+
+// Posts JSON straight to Better Auth (/api/auth/*); cookies are set by that response, so no extra
+// auth plugin is needed for server actions.
+export function AuthForm({ mode, invitationId }: { mode: "sign-in" | "sign-up"; invitationId?: string }) {
+ const router = useRouter();
+ const [message, setMessage] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ async function submit(event: FormEvent) {
+ event.preventDefault();
+ setBusy(true);
+ setMessage(null);
+ const form = new FormData(event.currentTarget);
+ const body = {
+ email: String(form.get("email") ?? ""),
+ password: String(form.get("password") ?? ""),
+ ...(mode === "sign-up" ? { name: String(form.get("name") ?? ""), invitationId } : {}),
+ };
+ const response = await fetch(`/api/auth/${mode}/email`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ setBusy(false);
+ if (mode === "sign-in") {
+ if (response.ok) {
+ router.push("/");
+ router.refresh();
+ }
+ else if (response.status === 429) setMessage("Zu viele Versuche – bitte später erneut versuchen.");
+ else setMessage("Anmeldung fehlgeschlagen. Bitte E-Mail und Passwort prüfen.");
+ return;
+ }
+ // Same answer for invited and uninvited addresses (no enumeration).
+ setMessage(
+ response.ok
+ ? "Falls für diese Adresse eine Einladung vorliegt, ist das Konto jetzt angelegt. Bitte anmelden."
+ : "Registrierung fehlgeschlagen. Passwort mindestens 12 Zeichen.",
+ );
+ }
+
+ return (
+
+ );
+}
+
+export function SignOutButton() {
+ const router = useRouter();
+ async function signOut() {
+ await fetch("/api/auth/sign-out", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
+ router.push("/login");
+ router.refresh();
+ }
+ return (
+
+ );
+}
diff --git a/src/app/_server/runtime.ts b/src/app/_server/runtime.ts
index 6b212fa..0317c1a 100644
--- a/src/app/_server/runtime.ts
+++ b/src/app/_server/runtime.ts
@@ -1,13 +1,17 @@
import { loadConfig, type AppConfig } from "@/config/env";
import { createDatabase, type DatabaseHandle } from "@/db";
+import { createAuth, getActor, type Actor, type Auth } from "@/features/identity";
import { S3BlobStore } from "@/features/storage";
+import { createTenancy, type Tenancy } from "@/features/tenancy";
-// Composition root of the web process: one pool and one storage client per process, created on
-// first use (never at import time, so `next build` needs no environment).
+// Composition root of the web process: one pool, one storage client and one auth instance per
+// process, created on first use (never at import time, so `next build` needs no environment).
export interface Runtime {
config: AppConfig;
database: DatabaseHandle;
storage: S3BlobStore;
+ auth: Auth;
+ tenancy: Tenancy;
}
let runtime: Runtime | undefined;
@@ -15,7 +19,20 @@ let runtime: Runtime | undefined;
export function getRuntime(): Runtime {
if (!runtime) {
const config = loadConfig();
- runtime = { config, database: createDatabase(config.databaseUrl), storage: new S3BlobStore(config.storage) };
+ const database = createDatabase(config.databaseUrl);
+ runtime = {
+ config,
+ database,
+ storage: new S3BlobStore(config.storage),
+ auth: createAuth(database.db, config.auth),
+ tenancy: createTenancy(database.db),
+ };
}
return runtime;
}
+
+/** The signed-in actor for the current request, or null. */
+export async function currentActor(headers: Headers): Promise {
+ const { auth, database } = getRuntime();
+ return getActor(auth, database.db, headers);
+}
diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts
new file mode 100644
index 0000000..0f223ec
--- /dev/null
+++ b/src/app/api/auth/[...all]/route.ts
@@ -0,0 +1,10 @@
+import { getRuntime } from "@/app/_server/runtime";
+
+export const dynamic = "force-dynamic";
+
+// Better Auth endpoints (/api/auth/*): sign-in, sign-up (invite-only), session, organization.
+// Resolved per request so the build needs no environment.
+const handle = (request: Request) => getRuntime().auth.handler(request);
+
+export const GET = handle;
+export const POST = handle;
diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx
new file mode 100644
index 0000000..00ffdb9
--- /dev/null
+++ b/src/app/invite/page.tsx
@@ -0,0 +1,65 @@
+import { headers } from "next/headers";
+import { notFound, redirect } from "next/navigation";
+import { z } from "zod";
+import { currentActor, getRuntime } from "@/app/_server/runtime";
+import { authorize, AuthorizationError, inviteUser, COMPANY_ROLES, type Actor } from "@/features/identity";
+
+export const dynamic = "force-dynamic";
+
+const inviteInput = z.object({ email: z.email().max(254), role: z.enum(COMPANY_ROLES) });
+
+async function adminOrNotFound(): Promise {
+ const actor = await currentActor(await headers());
+ if (!actor) redirect("/login");
+ try {
+ authorize(actor, "users.invite");
+ } catch (error) {
+ if (error instanceof AuthorizationError) notFound();
+ throw error;
+ }
+ return actor;
+}
+
+// Invite form (pilot: no role-admin UI; e-mail delivery is out of scope). Authorization runs
+// server-side on render AND in the action. The admin hands the link over to the invited person.
+async function invite(formData: FormData) {
+ "use server";
+ const actor = await adminOrNotFound();
+ const input = inviteInput.safeParse({ email: formData.get("email"), role: formData.get("role") });
+ if (!input.success) redirect("/invite?error=input");
+ const { invitationId } = await inviteUser(getRuntime().database.db, actor, input.data);
+ redirect(`/invite?invitation=${invitationId}`);
+}
+
+export default async function InvitePage({ searchParams }: { searchParams: Promise> }) {
+ await adminOrNotFound();
+ const params = await searchParams;
+ const link = params.invitation ? `${getRuntime().config.auth.baseURL}/signup?invitation=${encodeURIComponent(params.invitation)}` : null;
+ return (
+
+ Mitarbeitende einladen
+ {link && (
+
+ Einladung angelegt (7 Tage gültig). Diesen Link an die eingeladene Person weitergeben: {link}
+
+ )}
+ {params.error && Bitte eine gültige E-Mail-Adresse und Rolle angeben.
}
+
+
+ );
+}
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
new file mode 100644
index 0000000..ddb9ef5
--- /dev/null
+++ b/src/app/login/page.tsx
@@ -0,0 +1,14 @@
+import Link from "next/link";
+import { AuthForm } from "@/app/_components/auth-form";
+
+export default function LoginPage() {
+ return (
+
+ Anmelden
+
+
+ Eingeladen worden? Konto anlegen
+
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index d408995..bdda04b 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,9 +1,38 @@
-export default function HomePage() {
+import Link from "next/link";
+import { headers } from "next/headers";
+import { SignOutButton } from "@/app/_components/auth-form";
+import { currentActor, getRuntime } from "@/app/_server/runtime";
+import { getCompany } from "@/features/identity";
+
+export const dynamic = "force-dynamic";
+
+export default async function HomePage() {
+ const actor = await currentActor(await headers());
+ if (!actor) {
+ return (
+
+ RequestFlow
+ Angebotsanfragen erfassen, neben der Quelle prüfen und genau einmal ans ERP übergeben.
+
+ Anmelden · Konto mit Einladung anlegen
+
+ Pilot – alle Daten sind synthetisch.
+
+ );
+ }
+ const company = await getCompany(getRuntime().database.db, actor.companyId);
return (
RequestFlow
- Angebotsanfragen erfassen, neben der Quelle prüfen und genau einmal ans ERP übergeben.
- Pilot im Aufbau – alle Daten sind synthetisch.
+
+ Firma: {company?.name} · Rolle: {actor.role === "admin" ? "Administration" : "Sachbearbeitung"}
+
+ {actor.role === "admin" && (
+
+ Mitarbeitende einladen
+
+ )}
+
);
}
diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx
new file mode 100644
index 0000000..9f049f1
--- /dev/null
+++ b/src/app/signup/page.tsx
@@ -0,0 +1,23 @@
+import Link from "next/link";
+import { AuthForm } from "@/app/_components/auth-form";
+
+// Invite-only: the link from the invitation carries its id (`/signup?invitation=`).
+export default async function SignupPage({ searchParams }: { searchParams: Promise> }) {
+ const { invitation } = await searchParams;
+ return (
+
+ Konto anlegen
+ {invitation ? (
+ <>
+ Verwenden Sie die E-Mail-Adresse, an die die Einladung ging.
+
+ >
+ ) : (
+ Ein Konto kann nur über einen Einladungslink angelegt werden. Bitte wenden Sie sich an Ihre Administration.
+ )}
+
+ Zur Anmeldung
+
+
+ );
+}
diff --git a/src/config/env.test.ts b/src/config/env.test.ts
index d12af79..9cf6a92 100644
--- a/src/config/env.test.ts
+++ b/src/config/env.test.ts
@@ -8,6 +8,9 @@ const valid = {
S3_BUCKET: "requestflow-documents",
S3_ACCESS_KEY_ID: "local-key",
S3_SECRET_ACCESS_KEY: "s3-secret-value",
+ BETTER_AUTH_SECRET: "test-only-secret-with-at-least-32-characters",
+ BETTER_AUTH_URL: "http://localhost:3000",
+ APP_ENV: "local",
};
describe("loadConfig", () => {
@@ -50,4 +53,25 @@ describe("loadConfig", () => {
it("reads S3_FORCE_PATH_STYLE=false as false", () => {
expect(loadConfig({ ...valid, S3_FORCE_PATH_STYLE: "false" }).storage.forcePathStyle).toBe(false);
});
+
+ it("refuses the committed local auth secret outside APP_ENV=local", () => {
+ const local = { ...valid, BETTER_AUTH_SECRET: "local-dev-only-secret-change-me-0123456789" };
+
+ expect(() => loadConfig({ ...local, APP_ENV: "production" })).toThrow(/BETTER_AUTH_SECRET/);
+ expect(() => loadConfig({ ...local, APP_ENV: "showcase" })).toThrow(/BETTER_AUTH_SECRET/);
+ expect(() => loadConfig(local)).not.toThrow();
+ });
+
+ it("requires an explicit APP_ENV", () => {
+ const { APP_ENV: _env, ...withoutEnv } = valid;
+
+ expect(() => loadConfig(withoutEnv)).toThrow(/APP_ENV/);
+ });
+
+ it("reads the client-IP headers and trusted proxies for the auth rate limit as lists", () => {
+ const config = loadConfig({ ...valid, AUTH_IP_HEADERS: "x-real-ip, x-forwarded-for", AUTH_TRUSTED_PROXIES: "10.0.0.2" });
+
+ expect(config.auth.ipAddressHeaders).toEqual(["x-real-ip", "x-forwarded-for"]);
+ expect(config.auth.trustedProxies).toEqual(["10.0.0.2"]);
+ });
});
diff --git a/src/config/env.ts b/src/config/env.ts
index 4605fdf..7df3d7d 100644
--- a/src/config/env.ts
+++ b/src/config/env.ts
@@ -8,8 +8,18 @@ const schema = z.object({
S3_ACCESS_KEY_ID: z.string().min(1),
S3_SECRET_ACCESS_KEY: z.string().min(1),
S3_FORCE_PATH_STYLE: z.enum(["true", "false"]).default("true"),
+ BETTER_AUTH_SECRET: z.string().min(32),
+ BETTER_AUTH_URL: z.url(),
+ AUTH_IP_HEADERS: z.string().default("x-forwarded-for"),
+ AUTH_TRUSTED_PROXIES: z.string().default(""),
+ // Deployment environment – set explicitly everywhere (compose, CI, .env). Only `local` may use the
+ // committed local-default secret.
+ APP_ENV: z.enum(["local", "showcase", "production"]),
});
+const LOCAL_PLACEHOLDER_SECRETS = new Set(["local-dev-only-secret-change-me-0123456789"]);
+const list = (value: string) => value.split(",").map((item) => item.trim()).filter(Boolean);
+
export interface AppConfig {
databaseUrl: string;
storage: {
@@ -20,6 +30,12 @@ export interface AppConfig {
secretAccessKey: string;
forcePathStyle: boolean;
};
+ auth: {
+ secret: string;
+ baseURL: string;
+ ipAddressHeaders: string[];
+ trustedProxies: string[];
+ };
}
// Errors list variable names only – values may be secrets and end up in logs.
@@ -30,6 +46,10 @@ export function loadConfig(source: Record = process.
throw new Error(`Invalid or missing configuration: ${names.join(", ")}`);
}
const env = parsed.data;
+ // The committed local default must never sign sessions of a real deployment.
+ if (env.APP_ENV !== "local" && LOCAL_PLACEHOLDER_SECRETS.has(env.BETTER_AUTH_SECRET)) {
+ throw new Error("Invalid or missing configuration: BETTER_AUTH_SECRET");
+ }
return {
databaseUrl: env.DATABASE_URL,
storage: {
@@ -40,5 +60,11 @@ export function loadConfig(source: Record = process.
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
forcePathStyle: env.S3_FORCE_PATH_STYLE === "true",
},
+ auth: {
+ secret: env.BETTER_AUTH_SECRET,
+ baseURL: env.BETTER_AUTH_URL,
+ ipAddressHeaders: list(env.AUTH_IP_HEADERS),
+ trustedProxies: list(env.AUTH_TRUSTED_PROXIES),
+ },
};
}
diff --git a/src/db/migrations/0001_identity_tenancy.sql b/src/db/migrations/0001_identity_tenancy.sql
new file mode 100644
index 0000000..e87aff5
--- /dev/null
+++ b/src/db/migrations/0001_identity_tenancy.sql
@@ -0,0 +1,119 @@
+-- Generated by drizzle-kit from src/db/schema/*; edited: schema `app` already exists (0000).
+CREATE SCHEMA "auth";
+--> statement-breakpoint
+CREATE TABLE "auth"."account" (
+ "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL,
+ "account_id" text NOT NULL,
+ "provider_id" text NOT NULL,
+ "user_id" uuid NOT NULL,
+ "access_token" text,
+ "refresh_token" text,
+ "id_token" text,
+ "access_token_expires_at" timestamp with time zone,
+ "refresh_token_expires_at" timestamp with time zone,
+ "scope" text,
+ "password" text,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "auth"."invitation" (
+ "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL,
+ "organization_id" uuid NOT NULL,
+ "email" text NOT NULL,
+ "role" text,
+ "status" text DEFAULT 'pending' NOT NULL,
+ "expires_at" timestamp with time zone NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "inviter_id" uuid NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "auth"."member" (
+ "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL,
+ "organization_id" uuid NOT NULL,
+ "user_id" uuid NOT NULL,
+ "role" text DEFAULT 'member' NOT NULL,
+ "created_at" timestamp with time zone NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "auth"."organization" (
+ "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL,
+ "name" text NOT NULL,
+ "slug" text NOT NULL,
+ "logo" text,
+ "created_at" timestamp with time zone NOT NULL,
+ "metadata" text,
+ CONSTRAINT "organization_slug_unique" UNIQUE("slug")
+);
+--> statement-breakpoint
+CREATE TABLE "auth"."rate_limit" (
+ "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL,
+ "key" text NOT NULL,
+ "count" integer NOT NULL,
+ "last_request" bigint NOT NULL,
+ CONSTRAINT "rate_limit_key_unique" UNIQUE("key")
+);
+--> statement-breakpoint
+CREATE TABLE "auth"."session" (
+ "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL,
+ "expires_at" timestamp with time zone NOT NULL,
+ "token" text NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone NOT NULL,
+ "ip_address" text,
+ "user_agent" text,
+ "user_id" uuid NOT NULL,
+ "active_organization_id" text,
+ "impersonated_by" text,
+ CONSTRAINT "session_token_unique" UNIQUE("token")
+);
+--> statement-breakpoint
+CREATE TABLE "auth"."user" (
+ "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL,
+ "name" text NOT NULL,
+ "email" text NOT NULL,
+ "email_verified" boolean DEFAULT false NOT NULL,
+ "image" text,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "role" text,
+ "banned" boolean DEFAULT false,
+ "ban_reason" text,
+ "ban_expires" timestamp with time zone,
+ CONSTRAINT "user_email_unique" UNIQUE("email")
+);
+--> statement-breakpoint
+CREATE TABLE "auth"."verification" (
+ "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL,
+ "identifier" text NOT NULL,
+ "value" text NOT NULL,
+ "expires_at" timestamp with time zone NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "app"."requests" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "company_id" uuid NOT NULL,
+ "status" text DEFAULT 'NEW' NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT "requests_status_check" CHECK (status in ('NEW', 'PROCESSING', 'REVIEW', 'APPROVED', 'EXPORTED', 'REJECTED', 'ERROR'))
+);
+--> statement-breakpoint
+ALTER TABLE "app"."requests" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
+ALTER TABLE "auth"."account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "auth"."invitation" ADD CONSTRAINT "invitation_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "auth"."invitation" ADD CONSTRAINT "invitation_inviter_id_user_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "auth"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "auth"."member" ADD CONSTRAINT "member_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "auth"."member" ADD CONSTRAINT "member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "auth"."session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "app"."requests" ADD CONSTRAINT "requests_company_id_organization_id_fk" FOREIGN KEY ("company_id") REFERENCES "auth"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "account_userId_idx" ON "auth"."account" USING btree ("user_id");--> statement-breakpoint
+CREATE INDEX "invitation_organizationId_idx" ON "auth"."invitation" USING btree ("organization_id");--> statement-breakpoint
+CREATE INDEX "invitation_email_idx" ON "auth"."invitation" USING btree ("email");--> statement-breakpoint
+CREATE INDEX "member_organizationId_idx" ON "auth"."member" USING btree ("organization_id");--> statement-breakpoint
+CREATE INDEX "member_userId_idx" ON "auth"."member" USING btree ("user_id");--> statement-breakpoint
+CREATE INDEX "session_userId_idx" ON "auth"."session" USING btree ("user_id");--> statement-breakpoint
+CREATE INDEX "verification_identifier_idx" ON "auth"."verification" USING btree ("identifier");--> statement-breakpoint
+CREATE INDEX "requests_company_id_idx" ON "app"."requests" USING btree ("company_id");--> statement-breakpoint
+CREATE POLICY "requests_tenant_isolation" ON "app"."requests" AS PERMISSIVE FOR ALL TO public USING (company_id = nullif(current_setting('app.company_id', true), '')::uuid) WITH CHECK (company_id = nullif(current_setting('app.company_id', true), '')::uuid);
\ No newline at end of file
diff --git a/src/db/migrations/0002_auth_grants_force_rls.sql b/src/db/migrations/0002_auth_grants_force_rls.sql
new file mode 100644
index 0000000..e7373f5
--- /dev/null
+++ b/src/db/migrations/0002_auth_grants_force_rls.sql
@@ -0,0 +1,17 @@
+-- Hand-written (ADR-0001 D7).
+-- 1) The runtime role may use the Better Auth tables (schema `auth`, no RLS – exceptions register),
+-- but never create objects there.
+GRANT USAGE ON SCHEMA auth TO app_rw;
+--> statement-breakpoint
+REVOKE ALL ON SCHEMA auth FROM PUBLIC;
+--> statement-breakpoint
+GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA auth TO app_rw;
+--> statement-breakpoint
+ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA auth GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_rw;
+--> statement-breakpoint
+-- 2) Forced RLS: drizzle-kit emits only ENABLE. FORCE makes the policy apply to the table owner too,
+-- so no role except a superuser ever reads company data without `app.company_id`.
+ALTER TABLE app.requests FORCE ROW LEVEL SECURITY;
+--> statement-breakpoint
+-- 3) One company per user in the pilot: the session hook and getActor resolve THE membership.
+CREATE UNIQUE INDEX member_one_company_per_user ON auth.member (user_id);
diff --git a/src/db/migrations/meta/0001_snapshot.json b/src/db/migrations/meta/0001_snapshot.json
new file mode 100644
index 0000000..ca20ee7
--- /dev/null
+++ b/src/db/migrations/meta/0001_snapshot.json
@@ -0,0 +1,840 @@
+{
+ "id": "1a5f1fcb-ba40-4aed-b2cc-a9acb1e65375",
+ "prevId": "bb3fbb0f-c63b-429b-bdde-dd62b4f1e186",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "auth.account": {
+ "name": "account",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "account_userId_idx": {
+ "name": "account_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.invitation": {
+ "name": "invitation",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "invitation_organizationId_idx": {
+ "name": "invitation_organizationId_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "invitation_email_idx": {
+ "name": "invitation_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "invitation_organization_id_organization_id_fk": {
+ "name": "invitation_organization_id_organization_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invitation_inviter_id_user_id_fk": {
+ "name": "invitation_inviter_id_user_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "inviter_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.member": {
+ "name": "member",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'member'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "member_organizationId_idx": {
+ "name": "member_organizationId_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "member_userId_idx": {
+ "name": "member_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "member_organization_id_organization_id_fk": {
+ "name": "member_organization_id_organization_id_fk",
+ "tableFrom": "member",
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "member_user_id_user_id_fk": {
+ "name": "member_user_id_user_id_fk",
+ "tableFrom": "member",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.organization": {
+ "name": "organization",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "logo": {
+ "name": "logo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "organization_slug_unique": {
+ "name": "organization_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.rate_limit": {
+ "name": "rate_limit",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "count": {
+ "name": "count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_request": {
+ "name": "last_request",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "rate_limit_key_unique": {
+ "name": "rate_limit_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.session": {
+ "name": "session",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "active_organization_id": {
+ "name": "active_organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "impersonated_by": {
+ "name": "impersonated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "session_userId_idx": {
+ "name": "session_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.user": {
+ "name": "user",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires": {
+ "name": "ban_expires",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.verification": {
+ "name": "verification",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "app.requests": {
+ "name": "requests",
+ "schema": "app",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'NEW'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "requests_company_id_idx": {
+ "name": "requests_company_id_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "requests_company_id_organization_id_fk": {
+ "name": "requests_company_id_organization_id_fk",
+ "tableFrom": "requests",
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {
+ "requests_tenant_isolation": {
+ "name": "requests_tenant_isolation",
+ "as": "PERMISSIVE",
+ "for": "ALL",
+ "to": [
+ "public"
+ ],
+ "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid",
+ "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid"
+ }
+ },
+ "checkConstraints": {
+ "requests_status_check": {
+ "name": "requests_status_check",
+ "value": "status in ('NEW', 'PROCESSING', 'REVIEW', 'APPROVED', 'EXPORTED', 'REJECTED', 'ERROR')"
+ }
+ },
+ "isRLSEnabled": true
+ }
+ },
+ "enums": {},
+ "schemas": {
+ "auth": "auth",
+ "app": "app"
+ },
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/src/db/migrations/meta/0002_snapshot.json b/src/db/migrations/meta/0002_snapshot.json
new file mode 100644
index 0000000..0d13d41
--- /dev/null
+++ b/src/db/migrations/meta/0002_snapshot.json
@@ -0,0 +1,840 @@
+{
+ "id": "3fa69086-6743-434f-a277-58a30576189a",
+ "prevId": "1a5f1fcb-ba40-4aed-b2cc-a9acb1e65375",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "auth.account": {
+ "name": "account",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "account_userId_idx": {
+ "name": "account_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.invitation": {
+ "name": "invitation",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "invitation_organizationId_idx": {
+ "name": "invitation_organizationId_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ },
+ "invitation_email_idx": {
+ "name": "invitation_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "invitation_organization_id_organization_id_fk": {
+ "name": "invitation_organization_id_organization_id_fk",
+ "tableFrom": "invitation",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ },
+ "invitation_inviter_id_user_id_fk": {
+ "name": "invitation_inviter_id_user_id_fk",
+ "tableFrom": "invitation",
+ "columnsFrom": [
+ "inviter_id"
+ ],
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.member": {
+ "name": "member",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'member'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "member_organizationId_idx": {
+ "name": "member_organizationId_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ },
+ "member_userId_idx": {
+ "name": "member_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "member_organization_id_organization_id_fk": {
+ "name": "member_organization_id_organization_id_fk",
+ "tableFrom": "member",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ },
+ "member_user_id_user_id_fk": {
+ "name": "member_user_id_user_id_fk",
+ "tableFrom": "member",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.organization": {
+ "name": "organization",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "logo": {
+ "name": "logo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "organization_slug_unique": {
+ "name": "organization_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "nullsNotDistinct": false
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.rate_limit": {
+ "name": "rate_limit",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "count": {
+ "name": "count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_request": {
+ "name": "last_request",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "rate_limit_key_unique": {
+ "name": "rate_limit_key_unique",
+ "columns": [
+ "key"
+ ],
+ "nullsNotDistinct": false
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.session": {
+ "name": "session",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "active_organization_id": {
+ "name": "active_organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "impersonated_by": {
+ "name": "impersonated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "session_userId_idx": {
+ "name": "session_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "columns": [
+ "token"
+ ],
+ "nullsNotDistinct": false
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.user": {
+ "name": "user",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires": {
+ "name": "ban_expires",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "nullsNotDistinct": false
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.verification": {
+ "name": "verification",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "app.requests": {
+ "name": "requests",
+ "schema": "app",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'NEW'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "requests_company_id_idx": {
+ "name": "requests_company_id_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "requests_company_id_organization_id_fk": {
+ "name": "requests_company_id_organization_id_fk",
+ "tableFrom": "requests",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "restrict"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {
+ "requests_tenant_isolation": {
+ "name": "requests_tenant_isolation",
+ "as": "PERMISSIVE",
+ "for": "ALL",
+ "to": [
+ "public"
+ ],
+ "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid",
+ "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid"
+ }
+ },
+ "checkConstraints": {
+ "requests_status_check": {
+ "name": "requests_status_check",
+ "value": "status in ('NEW', 'PROCESSING', 'REVIEW', 'APPROVED', 'EXPORTED', 'REJECTED', 'ERROR')"
+ }
+ },
+ "isRLSEnabled": true
+ }
+ },
+ "enums": {},
+ "schemas": {
+ "auth": "auth",
+ "app": "app"
+ },
+ "views": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json
index 1709b0c..50a58ea 100644
--- a/src/db/migrations/meta/_journal.json
+++ b/src/db/migrations/meta/_journal.json
@@ -8,6 +8,20 @@
"when": 1790113205929,
"tag": "0000_app_schema",
"breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "7",
+ "when": 1790142267590,
+ "tag": "0001_identity_tenancy",
+ "breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "7",
+ "when": 1790142279511,
+ "tag": "0002_auth_grants_force_rls",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/db/schema.ts b/src/db/schema.ts
deleted file mode 100644
index 2e17b2c..0000000
--- a/src/db/schema.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-// Drizzle schema of the `app` schema. Tables arrive with the feature issues (#4 onwards);
-// every company-owned table gets `company_id` + forced RLS (ADR-0001 D7).
-export {};
diff --git a/src/db/schema/app.ts b/src/db/schema/app.ts
new file mode 100644
index 0000000..c28c0b8
--- /dev/null
+++ b/src/db/schema/app.ts
@@ -0,0 +1,43 @@
+// Company-owned business data (schema `app`). Every table has `company_id` + RLS enabled AND forced
+// (FORCE is added by a hand-written migration – drizzle-kit only emits ENABLE) with the policy
+// `tenant_isolation` (ADR-0001 D7). Access only via `withTenant()` as `app_rw`.
+import { sql } from "drizzle-orm";
+import { check, index, pgPolicy, pgSchema, text, timestamp, uuid } from "drizzle-orm/pg-core";
+import { organization } from "./auth";
+
+export const appSchema = pgSchema("app");
+
+/** `app.company_id` is set transaction-locally by `withTenant()`; unset → NULL → no row matches. */
+export const currentCompany = sql`nullif(current_setting('app.company_id', true), '')::uuid`;
+
+export const tenantPolicy = (table: string) =>
+ pgPolicy(`${table}_tenant_isolation`, {
+ as: "permissive",
+ for: "all",
+ to: "public",
+ using: sql`company_id = ${currentCompany}`,
+ withCheck: sql`company_id = ${currentCompany}`,
+ });
+
+export const REQUEST_STATUSES = ["NEW", "PROCESSING", "REVIEW", "APPROVED", "EXPORTED", "REJECTED", "ERROR"] as const;
+export type RequestStatus = (typeof REQUEST_STATUSES)[number];
+
+// Minimal request aggregate – the first tenant table (#4). #5 and #7 extend it additively.
+export const requests = appSchema
+ .table(
+ "requests",
+ {
+ id: uuid("id").default(sql`gen_random_uuid()`).primaryKey(),
+ companyId: uuid("company_id")
+ .notNull()
+ .references(() => organization.id, { onDelete: "restrict" }),
+ status: text("status").$type().default("NEW").notNull(),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ },
+ (table) => [
+ index("requests_company_id_idx").on(table.companyId),
+ check("requests_status_check", sql.raw(`status in (${REQUEST_STATUSES.map((s) => `'${s}'`).join(", ")})`)),
+ tenantPolicy("requests"),
+ ],
+ )
+ .enableRLS();
diff --git a/src/db/schema/auth.ts b/src/db/schema/auth.ts
new file mode 100644
index 0000000..7c972e0
--- /dev/null
+++ b/src/db/schema/auth.ts
@@ -0,0 +1,215 @@
+// Better Auth tables (schema `auth`), generated with `pnpm dlx auth@1.7.5 generate` (Better Auth CLI)
+// and adapted: timestamps with time zone. Not company-owned business data → no RLS; reachable only
+// by server code (ADR-0001 D7, exceptions register). Regenerate on a Better Auth upgrade.
+import { relations, sql } from "drizzle-orm";
+import {
+ pgSchema,
+ text,
+ bigint,
+ timestamp,
+ boolean,
+ integer,
+ uuid,
+ index,
+} from "drizzle-orm/pg-core";
+
+export const authSchema = pgSchema("auth");
+
+export const user = authSchema.table("user", {
+ id: uuid("id")
+ .default(sql`pg_catalog.gen_random_uuid()`)
+ .primaryKey(),
+ name: text("name").notNull(),
+ email: text("email").notNull().unique(),
+ emailVerified: boolean("email_verified").default(false).notNull(),
+ image: text("image"),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true })
+ .defaultNow()
+ .$onUpdate(() => /* @__PURE__ */ new Date())
+ .notNull(),
+ role: text("role"),
+ banned: boolean("banned").default(false),
+ banReason: text("ban_reason"),
+ banExpires: timestamp("ban_expires", { withTimezone: true }),
+});
+
+export const session = authSchema.table(
+ "session",
+ {
+ id: uuid("id")
+ .default(sql`pg_catalog.gen_random_uuid()`)
+ .primaryKey(),
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
+ token: text("token").notNull().unique(),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true })
+ .$onUpdate(() => /* @__PURE__ */ new Date())
+ .notNull(),
+ ipAddress: text("ip_address"),
+ userAgent: text("user_agent"),
+ userId: uuid("user_id")
+ .notNull()
+ .references(() => user.id, { onDelete: "cascade" }),
+ activeOrganizationId: text("active_organization_id"),
+ impersonatedBy: text("impersonated_by"),
+ },
+ (table) => [index("session_userId_idx").on(table.userId)],
+);
+
+export const account = authSchema.table(
+ "account",
+ {
+ id: uuid("id")
+ .default(sql`pg_catalog.gen_random_uuid()`)
+ .primaryKey(),
+ accountId: text("account_id").notNull(),
+ providerId: text("provider_id").notNull(),
+ userId: uuid("user_id")
+ .notNull()
+ .references(() => user.id, { onDelete: "cascade" }),
+ accessToken: text("access_token"),
+ refreshToken: text("refresh_token"),
+ idToken: text("id_token"),
+ accessTokenExpiresAt: timestamp("access_token_expires_at", { withTimezone: true }),
+ refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { withTimezone: true }),
+ scope: text("scope"),
+ password: text("password"),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true })
+ .$onUpdate(() => /* @__PURE__ */ new Date())
+ .notNull(),
+ },
+ (table) => [index("account_userId_idx").on(table.userId)],
+);
+
+export const verification = authSchema.table(
+ "verification",
+ {
+ id: uuid("id")
+ .default(sql`pg_catalog.gen_random_uuid()`)
+ .primaryKey(),
+ identifier: text("identifier").notNull(),
+ value: text("value").notNull(),
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true })
+ .defaultNow()
+ .$onUpdate(() => /* @__PURE__ */ new Date())
+ .notNull(),
+ },
+ (table) => [index("verification_identifier_idx").on(table.identifier)],
+);
+
+export const organization = authSchema.table("organization", {
+ id: uuid("id")
+ .default(sql`pg_catalog.gen_random_uuid()`)
+ .primaryKey(),
+ name: text("name").notNull(),
+ slug: text("slug").notNull().unique(),
+ logo: text("logo"),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
+ metadata: text("metadata"),
+});
+
+export const member = authSchema.table(
+ "member",
+ {
+ id: uuid("id")
+ .default(sql`pg_catalog.gen_random_uuid()`)
+ .primaryKey(),
+ organizationId: uuid("organization_id")
+ .notNull()
+ .references(() => organization.id, { onDelete: "cascade" }),
+ userId: uuid("user_id")
+ .notNull()
+ .references(() => user.id, { onDelete: "cascade" }),
+ role: text("role").default("member").notNull(),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
+ },
+ (table) => [
+ index("member_organizationId_idx").on(table.organizationId),
+ index("member_userId_idx").on(table.userId),
+ ],
+);
+
+export const invitation = authSchema.table(
+ "invitation",
+ {
+ id: uuid("id")
+ .default(sql`pg_catalog.gen_random_uuid()`)
+ .primaryKey(),
+ organizationId: uuid("organization_id")
+ .notNull()
+ .references(() => organization.id, { onDelete: "cascade" }),
+ email: text("email").notNull(),
+ role: text("role"),
+ status: text("status").default("pending").notNull(),
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ inviterId: uuid("inviter_id")
+ .notNull()
+ .references(() => user.id, { onDelete: "cascade" }),
+ },
+ (table) => [
+ index("invitation_organizationId_idx").on(table.organizationId),
+ index("invitation_email_idx").on(table.email),
+ ],
+);
+
+export const rateLimit = authSchema.table("rate_limit", {
+ id: uuid("id")
+ .default(sql`pg_catalog.gen_random_uuid()`)
+ .primaryKey(),
+ key: text("key").notNull().unique(),
+ count: integer("count").notNull(),
+ lastRequest: bigint("last_request", { mode: "number" }).notNull(),
+});
+
+export const userRelations = relations(user, ({ many }) => ({
+ sessions: many(session),
+ accounts: many(account),
+ members: many(member),
+ invitations: many(invitation),
+}));
+
+export const sessionRelations = relations(session, ({ one }) => ({
+ user: one(user, {
+ fields: [session.userId],
+ references: [user.id],
+ }),
+}));
+
+export const accountRelations = relations(account, ({ one }) => ({
+ user: one(user, {
+ fields: [account.userId],
+ references: [user.id],
+ }),
+}));
+
+export const organizationRelations = relations(organization, ({ many }) => ({
+ members: many(member),
+ invitations: many(invitation),
+}));
+
+export const memberRelations = relations(member, ({ one }) => ({
+ organization: one(organization, {
+ fields: [member.organizationId],
+ references: [organization.id],
+ }),
+ user: one(user, {
+ fields: [member.userId],
+ references: [user.id],
+ }),
+}));
+
+export const invitationRelations = relations(invitation, ({ one }) => ({
+ organization: one(organization, {
+ fields: [invitation.organizationId],
+ references: [organization.id],
+ }),
+ user: one(user, {
+ fields: [invitation.inviterId],
+ references: [user.id],
+ }),
+}));
diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts
new file mode 100644
index 0000000..378ac7f
--- /dev/null
+++ b/src/db/schema/index.ts
@@ -0,0 +1,4 @@
+// Drizzle schema: `auth` (Better Auth, no RLS – exceptions register) and `app` (company-owned,
+// forced RLS). Migrations in ../migrations are generated from here plus hand-written SQL.
+export * from "./auth";
+export * from "./app";
diff --git a/src/features/identity/access.ts b/src/features/identity/access.ts
new file mode 100644
index 0000000..93a0fe8
--- /dev/null
+++ b/src/features/identity/access.ts
@@ -0,0 +1,30 @@
+import { createAccessControl } from "better-auth/plugins/access";
+import { adminAc, userAc } from "better-auth/plugins/admin/access";
+import { defaultStatements } from "better-auth/plugins/organization/access";
+
+// Permissions of the Better Auth organization plugin's own HTTP endpoints (/api/auth/organization/*).
+// They mirror `authorize()`: only company admins invite or change members; clerks get nothing.
+// Deleting the organization (= company) is nobody's right in the pilot.
+export const organizationAc = createAccessControl(defaultStatements);
+
+export const organizationRoles = {
+ admin: organizationAc.newRole({
+ organization: ["update"],
+ member: ["create", "update", "delete"],
+ invitation: ["create", "cancel"],
+ team: [],
+ ac: ["read"],
+ }),
+ clerk: organizationAc.newRole({
+ organization: [],
+ member: [],
+ invitation: [],
+ team: [],
+ ac: ["read"],
+ }),
+};
+
+// Global roles of the Better Auth admin plugin. Every app user is `user` (no admin-plugin rights);
+// `platform-admin` exists only so the plugin has an admin role – nobody holds it in the pilot.
+export const PLATFORM_ADMIN_ROLE = "platform-admin";
+export const platformRoles = { user: userAc, [PLATFORM_ADMIN_ROLE]: adminAc };
diff --git a/src/features/identity/actor.ts b/src/features/identity/actor.ts
new file mode 100644
index 0000000..97332b6
--- /dev/null
+++ b/src/features/identity/actor.ts
@@ -0,0 +1,26 @@
+import { eq } from "drizzle-orm";
+import type { Database } from "@/db";
+import * as schema from "@/db/schema";
+import type { Auth } from "./auth";
+import { isCompanyRole, type Actor } from "./authorize";
+
+/**
+ * The signed-in actor of a request: user, company and company role (ADR-0001 D7). The company is
+ * the user's live membership (one company per user, unique index) – re-read on every call, so a
+ * removed membership takes effect immediately; never from client input. A session whose active
+ * organization disagrees with the membership fails closed. Better Auth clears the active
+ * organization after a refused switch; the membership still identifies the company then.
+ */
+export async function getActor(auth: Auth, db: Database, headers: Headers): Promise {
+ const session = await auth.api.getSession({ headers });
+ if (!session) return null;
+ const [membership] = await db
+ .select({ companyId: schema.member.organizationId, role: schema.member.role })
+ .from(schema.member)
+ .where(eq(schema.member.userId, session.user.id))
+ .limit(1);
+ if (!membership || !isCompanyRole(membership.role)) return null;
+ const active = session.session.activeOrganizationId;
+ if (active && active !== membership.companyId) return null;
+ return { userId: session.user.id, companyId: membership.companyId, role: membership.role };
+}
diff --git a/src/features/identity/auth.ts b/src/features/identity/auth.ts
new file mode 100644
index 0000000..f5a5188
--- /dev/null
+++ b/src/features/identity/auth.ts
@@ -0,0 +1,155 @@
+import { drizzleAdapter } from "@better-auth/drizzle-adapter";
+import { betterAuth } from "better-auth";
+import { APIError } from "better-auth/api";
+import { admin, organization } from "better-auth/plugins";
+import { and, asc, eq, gt, sql } from "drizzle-orm";
+import type { Database } from "@/db";
+import * as schema from "@/db/schema";
+import { organizationAc, organizationRoles, PLATFORM_ADMIN_ROLE, platformRoles } from "./access";
+import { isCompanyRole } from "./authorize";
+
+export interface AuthSettings {
+ secret: string;
+ baseURL: string;
+ /** Client-IP source for rate limiting; must match the deployment's proxy setup. */
+ ipAddressHeaders?: string[];
+ trustedProxies?: string[];
+ /** Rate limit on /api/auth/* (built-in, database storage). Tests may tighten it. */
+ rateLimit?: { window: number; max: number };
+}
+
+const lower = (value: string) => value.trim().toLowerCase();
+const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+const invitationIdOf = (context: { body?: unknown } | null) =>
+ (context?.body as { invitationId?: unknown } | undefined)?.invitationId;
+
+/**
+ * Better Auth for RequestFlow (ADR-0001 D6): e-mail + password, invite-only, organization = company,
+ * admin plugin without any global admin, rate limit stored in the database.
+ */
+export function createAuth(db: Database, settings: AuthSettings) {
+ // Invite-only: the sign-up must present the invitation id (a random UUID handed over as a link)
+ // AND the invited e-mail. An e-mail alone proves nothing – addresses are guessable and unverified.
+ const pendingInvitation = async (email: string, invitationId: unknown) => {
+ if (typeof invitationId !== "string" || !UUID.test(invitationId)) return undefined;
+ const [row] = await db
+ .select()
+ .from(schema.invitation)
+ .where(
+ and(
+ eq(schema.invitation.id, invitationId),
+ sql`lower(${schema.invitation.email}) = ${lower(email)}`,
+ eq(schema.invitation.status, "pending"),
+ gt(schema.invitation.expiresAt, new Date()),
+ ),
+ )
+ .limit(1);
+ return row;
+ };
+
+ const membershipOf = async (userId: string) => {
+ // One company per user (unique index on member.user_id); ordered for determinism anyway.
+ const [row] = await db.select().from(schema.member).where(eq(schema.member.userId, userId)).orderBy(asc(schema.member.createdAt)).limit(1);
+ return row;
+ };
+
+ return betterAuth({
+ secret: settings.secret,
+ baseURL: settings.baseURL,
+ basePath: "/api/auth",
+ database: drizzleAdapter(db, { provider: "pg", schema, transaction: true }),
+ advanced: {
+ database: { generateId: "uuid" },
+ ipAddress: {
+ ipAddressHeaders: settings.ipAddressHeaders ?? ["x-forwarded-for"],
+ ...(settings.trustedProxies?.length ? { trustedProxies: settings.trustedProxies } : {}),
+ },
+ },
+ emailAndPassword: { enabled: true, minPasswordLength: 12, autoSignIn: false },
+ session: { expiresIn: 60 * 60 * 8, updateAge: 60 * 60 },
+ rateLimit: {
+ enabled: true,
+ storage: "database",
+ window: settings.rateLimit?.window ?? 60,
+ max: settings.rateLimit?.max ?? 30,
+ customRules: {
+ "/sign-in/email": { window: 60, max: settings.rateLimit?.max ?? 5 },
+ "/sign-up/email": { window: 60, max: settings.rateLimit?.max ?? 5 },
+ },
+ },
+ plugins: [
+ organization({
+ allowUserToCreateOrganization: false,
+ creatorRole: "admin",
+ ac: organizationAc,
+ roles: organizationRoles,
+ disableOrganizationDeletion: true,
+ invitationExpiresIn: 60 * 60 * 24 * 7,
+ cancelPendingInvitationsOnReInvite: true,
+ // Only the pilot's two company roles – no plugin defaults (owner/member), no role lists.
+ organizationHooks: {
+ beforeCreateInvitation: async ({ invitation }) => {
+ if (!isCompanyRole(invitation.role)) throw new APIError("BAD_REQUEST", { message: "Unknown role." });
+ },
+ beforeUpdateMemberRole: async ({ newRole }) => {
+ if (!isCompanyRole(newRole)) throw new APIError("BAD_REQUEST", { message: "Unknown role." });
+ },
+ beforeAddMember: async ({ member }) => {
+ if (!isCompanyRole(member.role)) throw new APIError("BAD_REQUEST", { message: "Unknown role." });
+ },
+ },
+ }),
+ // Company admins are `member.role = admin`, never a global admin-plugin role, so they cannot use
+ // the plugin's cross-company endpoints (list/ban/impersonate users).
+ admin({
+ defaultRole: "user",
+ adminRoles: [PLATFORM_ADMIN_ROLE],
+ roles: platformRoles,
+ allowImpersonatingAdmins: false,
+ }),
+ ],
+ databaseHooks: {
+ user: {
+ create: {
+ // Invite-only: an account is created only for an e-mail with a pending, unexpired invitation.
+ before: async (user, context) => {
+ const invitation = await pendingInvitation(user.email, invitationIdOf(context));
+ if (!invitation) {
+ throw new APIError("FORBIDDEN", { message: "Registration requires an invitation." });
+ }
+ return { data: { ...user, email: lower(user.email), role: "user" } };
+ },
+ // The invitation becomes the membership: company and company role come from it.
+ after: async (user, context) => {
+ const invitation = await pendingInvitation(user.email, invitationIdOf(context));
+ if (!invitation || !isCompanyRole(invitation.role)) return;
+ await db.transaction(async (tx) => {
+ await tx.insert(schema.member).values({
+ organizationId: invitation.organizationId,
+ userId: user.id,
+ role: invitation.role as string,
+ createdAt: new Date(),
+ });
+ await tx
+ .update(schema.invitation)
+ .set({ status: "accepted" })
+ .where(eq(schema.invitation.id, invitation.id));
+ });
+ },
+ },
+ },
+ session: {
+ create: {
+ // A session always carries the user's company; no membership → no session (fail closed).
+ before: async (session) => {
+ const membership = await membershipOf(session.userId);
+ if (!membership || !isCompanyRole(membership.role)) return false;
+ return { data: { ...session, activeOrganizationId: membership.organizationId } };
+ },
+ },
+ },
+ },
+ });
+}
+
+export type Auth = ReturnType;
diff --git a/src/features/identity/authorize.test.ts b/src/features/identity/authorize.test.ts
new file mode 100644
index 0000000..111e087
--- /dev/null
+++ b/src/features/identity/authorize.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+import { authorize, AuthorizationError, isCompanyRole, type Actor } from "./authorize";
+
+const companyId = "6f1f3f4e-0000-4000-8000-000000000001";
+const admin: Actor = { userId: "u-admin", companyId, role: "admin" };
+const clerk: Actor = { userId: "u-clerk", companyId, role: "clerk" };
+
+describe("authorize", () => {
+ it("allows admins to manage users", () => {
+ expect(() => authorize(admin, "users.manage")).not.toThrow();
+ });
+
+ it("denies admin-only actions to clerks", () => {
+ expect(() => authorize(clerk, "users.manage")).toThrow(AuthorizationError);
+ expect(() => authorize(clerk, "users.invite")).toThrow(AuthorizationError);
+ });
+
+ it("allows both roles to process requests", () => {
+ expect(() => authorize(admin, "requests.process")).not.toThrow();
+ expect(() => authorize(clerk, "requests.process")).not.toThrow();
+ });
+
+ it("denies everything to an actor with an unknown role (fail closed)", () => {
+ const stranger = { userId: "u-x", companyId, role: "owner" } as unknown as Actor;
+
+ expect(() => authorize(stranger, "requests.process")).toThrow(AuthorizationError);
+ });
+
+ it("denies everything to an actor without a company", () => {
+ const orphan = { userId: "u-y", companyId: "", role: "admin" } as Actor;
+
+ expect(() => authorize(orphan, "requests.process")).toThrow(AuthorizationError);
+ });
+
+ it("recognises only the two pilot roles", () => {
+ expect(isCompanyRole("admin")).toBe(true);
+ expect(isCompanyRole("clerk")).toBe(true);
+ expect(isCompanyRole("owner")).toBe(false);
+ expect(isCompanyRole("member")).toBe(false);
+ });
+});
diff --git a/src/features/identity/authorize.ts b/src/features/identity/authorize.ts
new file mode 100644
index 0000000..d817c61
--- /dev/null
+++ b/src/features/identity/authorize.ts
@@ -0,0 +1,36 @@
+// Company roles and the single authorization decision (ADR-0001 D7). The role lives on the
+// membership (Better Auth `member.role`), never on the global user – a company admin has no rights
+// outside their own company.
+export const COMPANY_ROLES = ["admin", "clerk"] as const;
+export type CompanyRole = (typeof COMPANY_ROLES)[number];
+
+export interface Actor {
+ userId: string;
+ companyId: string;
+ role: CompanyRole;
+}
+
+export type Action = "requests.process" | "users.invite" | "users.manage";
+
+const PERMISSIONS: Record> = {
+ admin: new Set(["requests.process", "users.invite", "users.manage"]),
+ clerk: new Set(["requests.process"]),
+};
+
+export class AuthorizationError extends Error {
+ constructor(readonly action: Action) {
+ super(`not allowed: ${action}`);
+ this.name = "AuthorizationError";
+ }
+}
+
+export function isCompanyRole(value: unknown): value is CompanyRole {
+ return typeof value === "string" && (COMPANY_ROLES as readonly string[]).includes(value);
+}
+
+/** Throws unless the actor may perform the action. Unknown roles and missing companies fail closed. */
+export function authorize(actor: Actor, action: Action): void {
+ if (!actor.companyId || !isCompanyRole(actor.role) || !PERMISSIONS[actor.role].has(action)) {
+ throw new AuthorizationError(action);
+ }
+}
diff --git a/src/features/identity/companies.ts b/src/features/identity/companies.ts
new file mode 100644
index 0000000..3e516d8
--- /dev/null
+++ b/src/features/identity/companies.ts
@@ -0,0 +1,87 @@
+import { and, eq, sql } from "drizzle-orm";
+import type { Database } from "@/db";
+import * as schema from "@/db/schema";
+import { authorize, isCompanyRole, type Actor, type CompanyRole } from "./authorize";
+
+const INVITATION_DAYS = 7;
+// Invitations need an inviter (Better Auth schema). The first admin of a company is invited by this
+// system user: it has no password account and no membership, so it can never obtain a session.
+const SYSTEM_INVITER_EMAIL = "system@requestflow.invalid";
+
+const lower = (value: string) => value.trim().toLowerCase();
+const expiry = () => new Date(Date.now() + INVITATION_DAYS * 24 * 60 * 60 * 1000);
+
+export interface Company {
+ id: string;
+ name: string;
+ slug: string;
+}
+
+async function systemInviterId(db: Database): Promise {
+ await db
+ .insert(schema.user)
+ .values({ name: "RequestFlow system", email: SYSTEM_INVITER_EMAIL, role: "user" })
+ .onConflictDoNothing({ target: schema.user.email });
+ const [row] = await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.email, SYSTEM_INVITER_EMAIL));
+ if (!row) throw new Error("system inviter missing");
+ return row.id;
+}
+
+/** Operator action (seed script): a new company plus the invitation for its first admin. */
+export async function bootstrapCompany(
+ db: Database,
+ input: { name: string; slug: string; adminEmail: string },
+): Promise<{ company: Company; invitationId: string }> {
+ const inviterId = await systemInviterId(db);
+ return db.transaction(async (tx) => {
+ const [company] = await tx
+ .insert(schema.organization)
+ .values({ name: input.name, slug: input.slug, createdAt: new Date() })
+ .returning({ id: schema.organization.id, name: schema.organization.name, slug: schema.organization.slug });
+ if (!company) throw new Error("company insert returned no row");
+ const [invitation] = await tx
+ .insert(schema.invitation)
+ .values({ organizationId: company.id, email: lower(input.adminEmail), role: "admin", status: "pending", expiresAt: expiry(), inviterId })
+ .returning({ id: schema.invitation.id });
+ if (!invitation) throw new Error("invitation insert returned no row");
+ return { company, invitationId: invitation.id };
+ });
+}
+
+/** Company admins invite staff into their own company only – the company comes from the actor. */
+export async function inviteUser(
+ db: Database,
+ actor: Actor,
+ input: { email: string; role: CompanyRole },
+): Promise<{ invitationId: string }> {
+ authorize(actor, "users.invite");
+ if (!isCompanyRole(input.role)) throw new Error("unknown role");
+ const email = lower(input.email);
+ return db.transaction(async (tx) => {
+ // Re-inviting replaces a pending invitation of the same company.
+ await tx
+ .update(schema.invitation)
+ .set({ status: "canceled" })
+ .where(
+ and(
+ eq(schema.invitation.organizationId, actor.companyId),
+ sql`lower(${schema.invitation.email}) = ${email}`,
+ eq(schema.invitation.status, "pending"),
+ ),
+ );
+ const [row] = await tx
+ .insert(schema.invitation)
+ .values({ organizationId: actor.companyId, email, role: input.role, status: "pending", expiresAt: expiry(), inviterId: actor.userId })
+ .returning({ id: schema.invitation.id });
+ if (!row) throw new Error("invitation insert returned no row");
+ return { invitationId: row.id };
+ });
+}
+
+export async function getCompany(db: Database, companyId: string): Promise {
+ const [row] = await db
+ .select({ id: schema.organization.id, name: schema.organization.name, slug: schema.organization.slug })
+ .from(schema.organization)
+ .where(eq(schema.organization.id, companyId));
+ return row ?? null;
+}
diff --git a/src/features/identity/index.ts b/src/features/identity/index.ts
index 7ee9838..5ae0520 100644
--- a/src/features/identity/index.ts
+++ b/src/features/identity/index.ts
@@ -1,3 +1,5 @@
-// Public API of the `identity` module: Better Auth, users, companies, roles.
-// Other modules import only from this file (dependency-cruiser, ADR-0001 D1). Planned – see docs/technical/architecture.md.
-export {};
+// Public API of the `identity` module: Better Auth, companies, company roles (ADR-0001 D6/D7).
+export { createAuth, type Auth, type AuthSettings } from "./auth";
+export { getActor } from "./actor";
+export { bootstrapCompany, getCompany, inviteUser, type Company } from "./companies";
+export { authorize, AuthorizationError, isCompanyRole, COMPANY_ROLES, type Action, type Actor, type CompanyRole } from "./authorize";
diff --git a/src/features/requests/index.ts b/src/features/requests/index.ts
index 9ef6966..c1bbb37 100644
--- a/src/features/requests/index.ts
+++ b/src/features/requests/index.ts
@@ -1,3 +1,2 @@
-// Public API of the `requests` module: request aggregate, status machine.
-// Other modules import only from this file (dependency-cruiser, ADR-0001 D1). Planned – see docs/technical/architecture.md.
-export {};
+// Public API of the `requests` module: request aggregate (status machine follows with #7).
+export { listRequests, createRequest, type RequestRow } from "./repository";
diff --git a/src/features/requests/repository.ts b/src/features/requests/repository.ts
new file mode 100644
index 0000000..8e7c6e0
--- /dev/null
+++ b/src/features/requests/repository.ts
@@ -0,0 +1,24 @@
+import { desc } from "drizzle-orm";
+import { requests, type RequestStatus } from "@/db/schema";
+import { tenantOf, type TenantTx } from "@/features/tenancy";
+
+export interface RequestRow {
+ id: string;
+ companyId: string;
+ status: RequestStatus;
+ createdAt: Date;
+}
+
+// Repository of the request aggregate. Every function needs a tenant transaction; the company id is
+// taken from it, never from the caller – RLS enforces the same rule in the database.
+export async function listRequests(tx: TenantTx): Promise {
+ tenantOf(tx);
+ return tx.select().from(requests).orderBy(desc(requests.createdAt));
+}
+
+export async function createRequest(tx: TenantTx): Promise {
+ const companyId = tenantOf(tx);
+ const [row] = await tx.insert(requests).values({ companyId }).returning();
+ if (!row) throw new Error("insert returned no row");
+ return row;
+}
diff --git a/src/features/tenancy/index.ts b/src/features/tenancy/index.ts
index 8ff0367..9b7a7d1 100644
--- a/src/features/tenancy/index.ts
+++ b/src/features/tenancy/index.ts
@@ -1,3 +1,2 @@
-// Public API of the `tenancy` module: withTenant(), RLS policies.
-// Other modules import only from this file (dependency-cruiser, ADR-0001 D1). Planned – see docs/technical/architecture.md.
-export {};
+// Public API of the `tenancy` module: tenant context and forced RLS (ADR-0001 D7).
+export { createTenancy, tenantOf, MissingTenantError, type Tenancy, type TenantTx } from "./with-tenant";
diff --git a/src/features/tenancy/with-tenant.ts b/src/features/tenancy/with-tenant.ts
new file mode 100644
index 0000000..f6e0a47
--- /dev/null
+++ b/src/features/tenancy/with-tenant.ts
@@ -0,0 +1,44 @@
+import { sql } from "drizzle-orm";
+import type { Database } from "@/db";
+
+// Tenant context (ADR-0001 D7). `withTenant` opens a transaction, sets `app.company_id`
+// transaction-locally (safe with poolers: gone at COMMIT/ROLLBACK) and hands out a branded
+// transaction. Repositories accept only that brand, so a query without tenant context does not
+// compile – and `tenantOf()` re-checks at runtime.
+const TENANT = Symbol("tenant");
+
+type Transaction = Parameters[0]>[0];
+export type TenantTx = Transaction & { readonly [TENANT]: string };
+
+const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+export class MissingTenantError extends Error {
+ constructor() {
+ super("repository called without tenant context – use withTenant()");
+ this.name = "MissingTenantError";
+ }
+}
+
+export interface Tenancy {
+ withTenant(companyId: string, fn: (tx: TenantTx) => Promise): Promise;
+}
+
+export function createTenancy(db: Database): Tenancy {
+ return {
+ async withTenant(companyId, fn) {
+ if (!UUID.test(companyId)) throw new MissingTenantError();
+ return db.transaction(async (tx) => {
+ await tx.execute(sql`select set_config('app.company_id', ${companyId}, true)`);
+ Object.defineProperty(tx, TENANT, { value: companyId, enumerable: false });
+ return fn(tx as TenantTx);
+ });
+ },
+ };
+}
+
+/** The company of the current tenant transaction; throws if the transaction has no tenant. */
+export function tenantOf(tx: TenantTx): string {
+ const companyId = (tx as Partial>)[TENANT];
+ if (!companyId) throw new MissingTenantError();
+ return companyId;
+}
diff --git a/src/seed.ts b/src/seed.ts
new file mode 100644
index 0000000..5bfd5a6
--- /dev/null
+++ b/src/seed.ts
@@ -0,0 +1,50 @@
+// Demo seed (`pnpm seed:demo`, local only): two synthetic companies, each with an admin, and a clerk
+// in the first one. It uses the real path – company + invitation, then sign-up – no bypass.
+// All names and addresses are synthetic (example.com). Passwords come from SEED_PASSWORD.
+import { eq } from "drizzle-orm";
+import { loadConfig } from "@/config/env";
+import { createDatabase } from "@/db";
+import * as schema from "@/db/schema";
+import { bootstrapCompany, createAuth, getActor, inviteUser } from "@/features/identity";
+
+const COMPANIES = [
+ { name: "Musterbau Beispiel GmbH", slug: "musterbau", admin: "admin@musterbau.example.com", clerk: "sachbearbeitung@musterbau.example.com" },
+ { name: "Beispielwerk Nord AG", slug: "beispielwerk", admin: "admin@beispielwerk.example.com" },
+];
+
+async function main(): Promise {
+ const password = process.env.SEED_PASSWORD;
+ if (!password || password.length < 12) throw new Error("SEED_PASSWORD (at least 12 characters) is required");
+ const config = loadConfig();
+ const database = createDatabase(config.databaseUrl, { max: 2 });
+ const auth = createAuth(database.db, config.auth);
+ const signUp = (email: string, name: string, invitationId: string) =>
+ auth.api.signUpEmail({ body: { email, password, name, invitationId } as { email: string; password: string; name: string } });
+ try {
+ for (const company of COMPANIES) {
+ const [existing] = await database.db.select().from(schema.organization).where(eq(schema.organization.slug, company.slug));
+ if (existing) {
+ console.log(`skip ${company.slug}: exists`);
+ continue;
+ }
+ const { invitationId } = await bootstrapCompany(database.db, { name: company.name, slug: company.slug, adminEmail: company.admin });
+ await signUp(company.admin, "Demo Admin", invitationId);
+ if (company.clerk) {
+ const login = await auth.api.signInEmail({ body: { email: company.admin, password }, returnHeaders: true });
+ const cookie = login.headers.getSetCookie().map((line) => line.split(";")[0]).join("; ");
+ const admin = await getActor(auth, database.db, new Headers({ cookie }));
+ if (!admin) throw new Error("seeded admin has no company");
+ const invitation = await inviteUser(database.db, admin, { email: company.clerk, role: "clerk" });
+ await signUp(company.clerk, "Demo Sachbearbeitung", invitation.invitationId);
+ }
+ console.log(`seeded ${company.slug}`);
+ }
+ } finally {
+ await database.pool.end();
+ }
+}
+
+main().catch((error: unknown) => {
+ console.error(error instanceof Error ? error.message : "seed failed");
+ process.exit(1);
+});
diff --git a/tests/integration/helpers/stack.ts b/tests/integration/helpers/stack.ts
new file mode 100644
index 0000000..ce8a875
--- /dev/null
+++ b/tests/integration/helpers/stack.ts
@@ -0,0 +1,86 @@
+import { randomUUID } from "node:crypto";
+import { loadConfig } from "@/config/env";
+import { createDatabase, type DatabaseHandle } from "@/db";
+import { bootstrapCompany, createAuth, type Auth, type AuthSettings } from "@/features/identity";
+
+// Shared wiring for integration tests: the real app_rw pool, Better Auth over HTTP (auth.handler),
+// unique synthetic companies and e-mail addresses per run (the database persists between runs).
+export interface Stack {
+ database: DatabaseHandle;
+ auth: Auth;
+ close(): Promise;
+}
+
+export function createStack(overrides: Partial = {}): Stack {
+ const config = loadConfig();
+ const database = createDatabase(config.databaseUrl, { max: 4 });
+ const auth = createAuth(database.db, { ...config.auth, ...overrides });
+ return { database, auth, close: () => database.pool.end() };
+}
+
+export const unique = (prefix: string) => `${prefix}-${randomUUID().slice(0, 8)}`;
+export const syntheticEmail = (prefix: string) => `${unique(prefix)}@example.com`;
+export const PASSWORD = "synthetic-password-123";
+
+let ipCounter = 0;
+/** A fresh client IP per call site, so the rate limit of one test never bleeds into another. */
+export const freshIp = () => `198.51.100.${(ipCounter = (ipCounter % 250) + 1)}`;
+
+export async function call(
+ auth: Auth,
+ path: string,
+ init: { body?: unknown; cookie?: string; ip?: string; method?: string } = {},
+): Promise<{ status: number; body: unknown; cookie: string }> {
+ const headers = new Headers({
+ "content-type": "application/json",
+ origin: "http://localhost:3000",
+ "x-forwarded-for": init.ip ?? freshIp(),
+ });
+ if (init.cookie) headers.set("cookie", init.cookie);
+ const response = await auth.handler(
+ new Request(`http://localhost:3000/api/auth${path}`, {
+ method: init.method ?? (init.body === undefined ? "GET" : "POST"),
+ headers,
+ body: init.body === undefined ? undefined : JSON.stringify(init.body),
+ }),
+ );
+ const text = await response.text();
+ const cookie = response.headers
+ .getSetCookie()
+ .map((line) => line.split(";")[0])
+ .join("; ");
+ return { status: response.status, body: text ? JSON.parse(text) : null, cookie };
+}
+
+export async function signUp(auth: Auth, email: string, invitationId?: string) {
+ return call(auth, "/sign-up/email", { body: { email, password: PASSWORD, name: "Synthetic User", invitationId } });
+}
+
+export async function signIn(auth: Auth, email: string, ip?: string) {
+ return call(auth, "/sign-in/email", { body: { email, password: PASSWORD }, ip });
+}
+
+/** A company with a signed-in first admin. */
+export async function companyWithAdmin(stack: Stack) {
+ const adminEmail = syntheticEmail("admin");
+ const { company, invitationId } = await bootstrapCompany(stack.database.db, {
+ name: `Beispiel Maschinenbau ${unique("co")}`,
+ slug: unique("beispiel"),
+ adminEmail,
+ });
+ await signUp(stack.auth, adminEmail, invitationId);
+ const login = await signIn(stack.auth, adminEmail);
+ if (login.status !== 200) throw new Error(`sign-in failed: ${login.status}`);
+ return { company, adminEmail, cookie: login.cookie };
+}
+
+/** Invite a user into the actor's company and sign them in; returns the new user's cookie. */
+export async function invitedUser(stack: Stack, admin: import("@/features/identity").Actor, role: "admin" | "clerk") {
+ const { inviteUser } = await import("@/features/identity");
+ const email = syntheticEmail(role);
+ const { invitationId } = await inviteUser(stack.database.db, admin, { email, role });
+ await signUp(stack.auth, email, invitationId);
+ const login = await signIn(stack.auth, email);
+ if (login.status !== 200) throw new Error(`sign-in failed: ${login.status}`);
+ return { email, cookie: login.cookie };
+}
diff --git a/tests/integration/identity.test.ts b/tests/integration/identity.test.ts
new file mode 100644
index 0000000..572fab7
--- /dev/null
+++ b/tests/integration/identity.test.ts
@@ -0,0 +1,202 @@
+import { eq, sql } from "drizzle-orm";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import * as schema from "@/db/schema";
+import { AuthorizationError, getActor, inviteUser, type Actor } from "@/features/identity";
+import {
+ call,
+ companyWithAdmin,
+ createStack,
+ freshIp,
+ invitedUser,
+ signIn,
+ signUp,
+ syntheticEmail,
+ type Stack,
+} from "./helpers/stack";
+
+describe("identity: invite-only login and companies", () => {
+ let stack: Stack;
+ const actorOf = async (cookie: string) => (await getActor(stack.auth, stack.database.db, new Headers({ cookie }))) as Actor;
+ const userCount = async (email: string) =>
+ (await stack.database.db.select().from(schema.user).where(eq(schema.user.email, email.toLowerCase()))).length;
+
+ beforeAll(() => {
+ stack = createStack();
+ });
+
+ afterAll(async () => {
+ await stack.close();
+ });
+
+ // Better Auth answers a refused sign-up with the same generic response as a successful one
+ // (anti-enumeration: nobody learns which addresses are invited). "Rejected" is therefore proven by
+ // its effect: no user, no session token, no login.
+ it("rejects a sign-up without an invitation: no user, no token, no login", async () => {
+ const email = syntheticEmail("uninvited");
+
+ const result = await signUp(stack.auth, email);
+
+ expect((result.body as { token: unknown }).token).toBeNull();
+ expect(result.cookie).not.toMatch(/session_token/);
+ expect(await userCount(email)).toBe(0);
+ expect((await signIn(stack.auth, email)).status).toBe(401);
+ });
+
+ it("rejects an invited address without the invitation id, or with a wrong one (no takeover by e-mail alone)", async () => {
+ const { cookie } = await companyWithAdmin(stack);
+ const email = syntheticEmail("target");
+ await inviteUser(stack.database.db, await actorOf(cookie), { email, role: "clerk" });
+
+ await signUp(stack.auth, email);
+ await signUp(stack.auth, email, crypto.randomUUID());
+ await signUp(stack.auth, email, "not-a-uuid");
+
+ expect(await userCount(email)).toBe(0);
+ });
+
+ it("rejects the invitation id of one address for another address", async () => {
+ const { cookie } = await companyWithAdmin(stack);
+ const { invitationId } = await inviteUser(stack.database.db, await actorOf(cookie), { email: syntheticEmail("real"), role: "clerk" });
+ const attacker = syntheticEmail("attacker");
+
+ await signUp(stack.auth, attacker, invitationId);
+
+ expect(await userCount(attacker)).toBe(0);
+ });
+
+ it("gives an invited user a session that carries their active company and role", async () => {
+ const { company, cookie } = await companyWithAdmin(stack);
+
+ const session = await call(stack.auth, "/get-session", { cookie });
+ const actor = await actorOf(cookie);
+
+ expect((session.body as { session: { activeOrganizationId: string } }).session.activeOrganizationId).toBe(company.id);
+ expect(actor).toMatchObject({ companyId: company.id, role: "admin" });
+ });
+
+ it("uses UUIDs for companies, so company_id columns and the RLS cast match", async () => {
+ const { company } = await companyWithAdmin(stack);
+
+ expect(company.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
+ });
+
+ it("matches the invitation e-mail case-insensitively and consumes the invitation", async () => {
+ const { company, cookie } = await companyWithAdmin(stack);
+ const email = syntheticEmail("Clerk").toUpperCase();
+ const { invitationId } = await inviteUser(stack.database.db, await actorOf(cookie), { email: email.toLowerCase(), role: "clerk" });
+
+ await signUp(stack.auth, email, invitationId);
+ const login = await signIn(stack.auth, email.toLowerCase());
+
+ expect(await actorOf(login.cookie)).toMatchObject({ companyId: company.id, role: "clerk" });
+ const [invitation] = await stack.database.db.select().from(schema.invitation).where(eq(schema.invitation.id, invitationId));
+ expect(invitation?.status).toBe("accepted");
+ });
+
+ it("denies inviting to clerks – server-side function and the plugin's HTTP endpoint", async () => {
+ const { company, cookie } = await companyWithAdmin(stack);
+ const clerk = await invitedUser(stack, await actorOf(cookie), "clerk");
+
+ await expect(inviteUser(stack.database.db, await actorOf(clerk.cookie), { email: syntheticEmail("x"), role: "admin" })).rejects.toThrow(
+ AuthorizationError,
+ );
+ const viaPlugin = await call(stack.auth, "/organization/invite-member", {
+ cookie: clerk.cookie,
+ body: { email: syntheticEmail("y"), role: "admin", organizationId: company.id },
+ });
+ expect(viaPlugin.status).toBe(403);
+ });
+
+ it("accepts only the pilot roles through the plugin's HTTP endpoints", async () => {
+ const { company, cookie } = await companyWithAdmin(stack);
+
+ const owner = await call(stack.auth, "/organization/invite-member", {
+ cookie,
+ body: { email: syntheticEmail("o"), role: "owner", organizationId: company.id },
+ });
+
+ expect(owner.status).toBeGreaterThanOrEqual(400);
+ const pending = await stack.database.db.select().from(schema.invitation).where(sql`${schema.invitation.role} = 'owner'`);
+ expect(pending).toHaveLength(0);
+ });
+
+ it("gives a company admin no access to the global admin plugin (no cross-company user list)", async () => {
+ const { cookie } = await companyWithAdmin(stack);
+
+ const listUsers = await call(stack.auth, "/admin/list-users", { cookie });
+
+ expect([401, 403]).toContain(listUsers.status);
+ });
+
+ it("does not let a user create another company", async () => {
+ const { cookie } = await companyWithAdmin(stack);
+
+ const created = await call(stack.auth, "/organization/create", { cookie, body: { name: "Fremdfirma", slug: syntheticEmail("s") } });
+
+ expect(created.status).toBe(403);
+ });
+
+ it("refuses switching the active company to a foreign one and listing its members", async () => {
+ const a = await companyWithAdmin(stack);
+ const b = await companyWithAdmin(stack);
+
+ const switched = await call(stack.auth, "/organization/set-active", { cookie: a.cookie, body: { organizationId: b.company.id } });
+ const members = await call(stack.auth, `/organization/list-members?organizationId=${b.company.id}`, { cookie: a.cookie });
+
+ expect(switched.status).toBeGreaterThanOrEqual(400);
+ expect(members.status).toBeGreaterThanOrEqual(400);
+ expect((await actorOf(a.cookie)).companyId).toBe(a.company.id);
+ });
+
+ it("refuses removing the last admin of a company", async () => {
+ const { company, cookie } = await companyWithAdmin(stack);
+ const admin = await actorOf(cookie);
+
+ const removed = await call(stack.auth, "/organization/remove-member", {
+ cookie,
+ body: { memberIdOrEmail: admin.userId, organizationId: company.id },
+ });
+
+ expect(removed.status).toBeGreaterThanOrEqual(400);
+ expect(await actorOf(cookie)).not.toBeNull();
+ });
+
+ it("allows one company per user: a second membership is refused by the database", async () => {
+ const a = await companyWithAdmin(stack);
+ const b = await companyWithAdmin(stack);
+ const admin = await actorOf(a.cookie);
+
+ const second = stack.database.db
+ .insert(schema.member)
+ .values({ organizationId: b.company.id, userId: admin.userId, role: "clerk", createdAt: new Date() });
+
+ await expect(second).rejects.toThrow();
+ });
+
+ it("rejects a login without membership (fail closed)", async () => {
+ const { cookie, adminEmail } = await companyWithAdmin(stack);
+ const admin = await actorOf(cookie);
+ await stack.database.db.delete(schema.member).where(eq(schema.member.userId, admin.userId));
+
+ expect(await getActor(stack.auth, stack.database.db, new Headers({ cookie }))).toBeNull();
+ expect((await signIn(stack.auth, adminEmail)).status).not.toBe(200);
+ });
+
+ // Proves the limiter and its database storage. The client IP comes from the configured header,
+ // which only a trusted reverse proxy may set in a real deployment (see operations.md).
+ it("rate-limits repeated sign-ins over HTTP and stores the counter in the database", async () => {
+ const limited = createStack({ rateLimit: { window: 60, max: 3 } });
+ const ip = freshIp();
+ const email = syntheticEmail("brute");
+ try {
+ const statuses: number[] = [];
+ for (let attempt = 0; attempt < 5; attempt++) statuses.push((await signIn(limited.auth, email, ip)).status);
+
+ expect(statuses).toContain(429);
+ const rows = await limited.database.db.select().from(schema.rateLimit).where(sql`${schema.rateLimit.key} like ${`%${ip}%`}`);
+ expect(rows.length).toBeGreaterThan(0);
+ } finally {
+ await limited.close();
+ }
+ });
+});
diff --git a/tests/integration/tenancy.test.ts b/tests/integration/tenancy.test.ts
new file mode 100644
index 0000000..2b06f5c
--- /dev/null
+++ b/tests/integration/tenancy.test.ts
@@ -0,0 +1,117 @@
+import pg from "pg";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { createDatabase, type DatabaseHandle } from "@/db";
+import { bootstrapCompany } from "@/features/identity";
+import { createRequest, listRequests } from "@/features/requests";
+import { createTenancy, MissingTenantError, type Tenancy, type TenantTx } from "@/features/tenancy";
+import { unique } from "./helpers/stack";
+
+// Two synthetic companies, A and B. Proof of ADR-0001 D7 on both layers: repository and raw SQL as app_rw.
+describe("tenancy: withTenant and forced RLS", () => {
+ let database: DatabaseHandle;
+ let tenancy: Tenancy;
+ let companyA: string;
+ let companyB: string;
+
+ beforeAll(async () => {
+ database = createDatabase(process.env.DATABASE_URL!, { max: 4 });
+ tenancy = createTenancy(database.db);
+ const a = await bootstrapCompany(database.db, { name: "Firma A (synthetisch)", slug: unique("a"), adminEmail: `${unique("a")}@example.com` });
+ const b = await bootstrapCompany(database.db, { name: "Firma B (synthetisch)", slug: unique("b"), adminEmail: `${unique("b")}@example.com` });
+ companyA = a.company.id;
+ companyB = b.company.id;
+ await tenancy.withTenant(companyA, (tx) => createRequest(tx));
+ await tenancy.withTenant(companyB, (tx) => createRequest(tx));
+ });
+
+ afterAll(async () => {
+ await database.pool.end();
+ });
+
+ it("returns only the own company's requests through the repository", async () => {
+ const rowsA = await tenancy.withTenant(companyA, (tx) => listRequests(tx));
+ const rowsB = await tenancy.withTenant(companyB, (tx) => listRequests(tx));
+
+ expect(rowsA.length).toBeGreaterThan(0);
+ expect(rowsA.every((row) => row.companyId === companyA)).toBe(true);
+ expect(rowsB.every((row) => row.companyId === companyB)).toBe(true);
+ });
+
+ describe("raw SQL as app_rw", () => {
+ let client: pg.Client;
+
+ beforeAll(async () => {
+ client = new pg.Client({ connectionString: process.env.DATABASE_URL });
+ await client.connect();
+ });
+
+ afterAll(async () => {
+ await client.end();
+ });
+
+ it("sees only company A with app.company_id = A, even without a WHERE clause", async () => {
+ await client.query("begin");
+ await client.query("select set_config('app.company_id', $1, true)", [companyA]);
+ const { rows } = await client.query("select distinct company_id from app.requests");
+ await client.query("commit");
+
+ expect(rows.map((row) => row.company_id)).toEqual([companyA]);
+ });
+
+ it("sees no rows at all without a company context", async () => {
+ const { rows } = await client.query("select count(*)::int as n from app.requests");
+
+ expect(rows[0].n).toBe(0);
+ });
+
+ it("rejects inserting a row of company B while acting for company A", async () => {
+ await client.query("begin");
+ await client.query("select set_config('app.company_id', $1, true)", [companyA]);
+ const insert = client.query("insert into app.requests (company_id) values ($1)", [companyB]);
+
+ await expect(insert).rejects.toThrow(/row-level security/);
+ await client.query("rollback");
+ });
+
+ it("cannot move an own row to another company", async () => {
+ await client.query("begin");
+ await client.query("select set_config('app.company_id', $1, true)", [companyA]);
+ const update = client.query("update app.requests set company_id = $1", [companyB]);
+
+ await expect(update).rejects.toThrow(/row-level security/);
+ await client.query("rollback");
+ });
+ });
+
+ it("keeps the company setting transaction-local: a reused pooled connection has none", async () => {
+ const single = createDatabase(process.env.DATABASE_URL!, { max: 1 });
+ try {
+ await createTenancy(single.db).withTenant(companyA, (tx) => listRequests(tx));
+
+ const { rows } = await single.pool.query(
+ "select coalesce(current_setting('app.company_id', true), '') as company, (select count(*)::int from app.requests) as n",
+ );
+ expect(rows[0]).toEqual({ company: "", n: 0 });
+ } finally {
+ await single.pool.end();
+ }
+ });
+
+ it("has RLS enabled and forced on app.requests", async () => {
+ const { rows } = await database.pool.query(
+ "select relrowsecurity, relforcerowsecurity from pg_class where oid = 'app.requests'::regclass",
+ );
+
+ expect(rows[0]).toEqual({ relrowsecurity: true, relforcerowsecurity: true });
+ });
+
+ it("refuses repository calls without a tenant transaction", async () => {
+ const plain = await database.db.transaction(async (tx) => listRequests(tx as TenantTx).catch((error: unknown) => error));
+
+ expect(plain).toBeInstanceOf(MissingTenantError);
+ });
+
+ it("refuses a company id that is not a UUID", async () => {
+ await expect(tenancy.withTenant("' or 1=1 --", (tx) => listRequests(tx))).rejects.toThrow(MissingTenantError);
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
index 3632749..3d1ac06 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -15,6 +15,9 @@ const localStackDefaults: Record = {
S3_ACCESS_KEY_ID: "local-access-key",
S3_SECRET_ACCESS_KEY: "local-secret-key",
S3_FORCE_PATH_STYLE: "true",
+ BETTER_AUTH_SECRET: "local-dev-only-secret-change-me-0123456789",
+ BETTER_AUTH_URL: "http://localhost:3000",
+ APP_ENV: "local",
};
const integrationEnv = Object.fromEntries(
Object.entries(localStackDefaults).map(([name, value]) => [name, process.env[name] ?? value]),