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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ This file records what changes **in the product** – process and session state
- AI service logs use the web/worker format: `time` (ISO 8601, `Z`) instead of `ts`, lower-case pino level labels (`warn`, not `WARNING`), `logger` only on library records.
- The request list reads each page from an index in its sort order instead of sorting all of the
company's requests (#61).
- The Better Auth tables live in the schema `identity` instead of `auth`, which Supabase reserves for
its own Auth service (#60): a fresh database creates `identity` directly; migration 0018 renames an
existing `auth` schema only when the app owns it.

### Fixed
- AI verifier: a unit quoted together with the neighbouring table cell (e.g. `60 | Stk.`) is now
Expand Down
2 changes: 1 addition & 1 deletion docs/technical/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Deliberately accepted risks – without an entry here a deviation counts as a de

| Exception | Why accepted | Owner | Expires |
|---|---|---|---|
| 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) |
| No RLS on the `identity` (Better 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; decided in #30: kept – its `banned` field implements deactivation (sign-in blocked by the plugin). Nobody holds `platform-admin`, so `/api/auth/admin/*` rejects every caller (tested); user management runs through `identity` | Fluory | 2026-12-31 (review at M3) |
| Upload endpoint without a per-user rate limit | Authenticated staff only; body bounded by `Content-Length` + `UPLOAD_MAX_REQUEST_BYTES` before reading | Fluory | before any public deployment (#19) |
Expand Down
14 changes: 7 additions & 7 deletions docs/technical/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
| Schema | Owner | Runtime access (`app_rw`) | Tenant isolation |
|---|---|---|---|
| `app` | `app_owner` | DML via default privileges, no CREATE | every table: `company_id` + RLS **enabled and forced**, policy `<table>_tenant_isolation` |
| `auth` | `app_owner` | DML on all tables, no CREATE | none – Better Auth data, server code only (exceptions register) |
| `identity` (until #60: `auth`) | `app_owner` | DML on all tables, no CREATE | none – Better Auth data, server code only (exceptions register) |
| `pgboss` | `app_owner` (deploy step installs schema + queues) | DML only | none – job queue, IDs only (exceptions register) |
| `drizzle` | `app_owner` | none | migration journal |

Expand All @@ -24,7 +24,7 @@ query sees zero rows and every write fails.
| Column | Type | Notes | Class |
|---|---|---|---|
| `id` | uuid PK | `gen_random_uuid()` | internal |
| `company_id` | uuid FK → `auth.organization.id` | tenant key, `ON DELETE RESTRICT` | internal |
| `company_id` | uuid FK → `identity.organization.id` | tenant key, `ON DELETE RESTRICT` | internal |
| `status` | text | `NEW · PROCESSING · REVIEW · APPROVED · EXPORTED · REJECTED · ERROR` (check constraint) | internal |
| `created_at` | timestamptz | | internal |
| `source` | text | `upload` (mailbox later) | internal |
Expand Down Expand Up @@ -60,7 +60,7 @@ All three: `company_id`, forced RLS, composite FKs to the run and request of the
| `id`, `company_id`, `request_id` | uuid | composite FK `(request_id, company_id)` → `requests` | internal |
| `field_key`, `item_index` | text, int | header field key (six, schema v2), or a line-item field key with its position (`item_index`, #25; null for header fields; check `item_index is null or item_index >= 0`, #47) | internal |
| `old_value`, `new_value` | text | value before / after; the newest row is the current value | confidential + personal |
| `corrected_by`, `created_at` | uuid, timestamptz | who and when; no FK to `auth.user` (like `audit_events.actor_user_id`) – the history must survive a user's removal | personal (staff) |
| `corrected_by`, `created_at` | uuid, timestamptz | who and when; no FK to `identity.user` (like `audit_events.actor_user_id`) – the history must survive a user's removal | personal (staff) |

Append-only: forced RLS, `app_rw` has INSERT/SELECT only (UPDATE/DELETE/TRUNCATE revoked) – the
history is the correction audit. The page shows a corrected value as `korrigiert`, never as `found`
Expand Down Expand Up @@ -103,7 +103,7 @@ The bytes (confidential + personal) live only in object storage; served via `GET
`app_rw` has INSERT and SELECT only (UPDATE/DELETE/TRUNCATE revoked). Written in the same transaction
as the change.

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

| Table | Content | Class | Purpose |
|---|---|---|---|
Expand All @@ -122,9 +122,9 @@ 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)
identity.organization 1─n identity.member n─1 identity.user 1─n identity.session / identity.account
identity.organization 1─n identity.invitation
identity.organization 1─n app.requests (company_id)
app.requests 1─n app.documents (request_id, company_id)
app.requests 0─1 app.requests (duplicate_of_id, company_id)
app.requests 1─n app.extraction_runs (request_id, company_id) 1─n segments / fields
Expand Down
2 changes: 1 addition & 1 deletion docs/technical/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ the secret manager), before the first `setup` run; the first migration refuses t
## 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
`identity.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).
Expand Down
2 changes: 1 addition & 1 deletion drizzle.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export default defineConfig({
dialect: "postgresql",
schema: "./src/db/schema/index.ts",
out: "./src/db/migrations",
schemaFilter: ["app", "auth"],
schemaFilter: ["app", "identity"],
migrations: { schema: "drizzle" },
dbCredentials: { url: process.env.MIGRATION_DATABASE_URL ?? "" },
});
46 changes: 23 additions & 23 deletions src/db/migrations/0001_identity_tenancy.sql
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
-- Generated by drizzle-kit from src/db/schema/*; edited: schema `app` already exists (0000).
CREATE SCHEMA "auth";
CREATE SCHEMA "identity";
--> statement-breakpoint
CREATE TABLE "auth"."account" (
CREATE TABLE "identity"."account" (
"id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
Expand All @@ -17,7 +17,7 @@ CREATE TABLE "auth"."account" (
"updated_at" timestamp with time zone NOT NULL
);
--> statement-breakpoint
CREATE TABLE "auth"."invitation" (
CREATE TABLE "identity"."invitation" (
"id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"email" text NOT NULL,
Expand All @@ -28,15 +28,15 @@ CREATE TABLE "auth"."invitation" (
"inviter_id" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "auth"."member" (
CREATE TABLE "identity"."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" (
CREATE TABLE "identity"."organization" (
"id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"slug" text NOT NULL,
Expand All @@ -46,15 +46,15 @@ CREATE TABLE "auth"."organization" (
CONSTRAINT "organization_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "auth"."rate_limit" (
CREATE TABLE "identity"."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" (
CREATE TABLE "identity"."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,
Expand All @@ -68,7 +68,7 @@ CREATE TABLE "auth"."session" (
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "auth"."user" (
CREATE TABLE "identity"."user" (
"id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
Expand All @@ -83,7 +83,7 @@ CREATE TABLE "auth"."user" (
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "auth"."verification" (
CREATE TABLE "identity"."verification" (
"id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
Expand All @@ -101,19 +101,19 @@ CREATE TABLE "app"."requests" (
);
--> 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
ALTER TABLE "identity"."account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "identity"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "identity"."invitation" ADD CONSTRAINT "invitation_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "identity"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "identity"."invitation" ADD CONSTRAINT "invitation_inviter_id_user_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "identity"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "identity"."member" ADD CONSTRAINT "member_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "identity"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "identity"."member" ADD CONSTRAINT "member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "identity"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "identity"."session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "identity"."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 "identity"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "account_userId_idx" ON "identity"."account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "invitation_organizationId_idx" ON "identity"."invitation" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "invitation_email_idx" ON "identity"."invitation" USING btree ("email");--> statement-breakpoint
CREATE INDEX "member_organizationId_idx" ON "identity"."member" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "member_userId_idx" ON "identity"."member" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "session_userId_idx" ON "identity"."session" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "verification_identifier_idx" ON "identity"."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);
12 changes: 6 additions & 6 deletions src/db/migrations/0002_auth_grants_force_rls.sql
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
-- Hand-written (ADR-0001 D7).
-- 1) The runtime role may use the Better Auth tables (schema `auth`, no RLS – exceptions register),
-- 1) The runtime role may use the Better Auth tables (schema `identity` – named `auth` until #60 –, no RLS – exceptions register),
-- but never create objects there.
GRANT USAGE ON SCHEMA auth TO app_rw;
GRANT USAGE ON SCHEMA identity TO app_rw;
--> statement-breakpoint
REVOKE ALL ON SCHEMA auth FROM PUBLIC;
REVOKE ALL ON SCHEMA identity FROM PUBLIC;
--> statement-breakpoint
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA auth TO app_rw;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA identity 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;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA identity 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);
CREATE UNIQUE INDEX member_one_company_per_user ON identity.member (user_id);
4 changes: 2 additions & 2 deletions src/db/migrations/0003_intake.sql
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ ALTER TABLE "app"."requests" ADD COLUMN "message_id" text;--> statement-breakpoi
ALTER TABLE "app"."requests" ADD COLUMN "fingerprint" text;--> statement-breakpoint
ALTER TABLE "app"."requests" ADD COLUMN "possible_duplicate" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "app"."requests" ADD COLUMN "duplicate_of_id" uuid;--> statement-breakpoint
ALTER TABLE "app"."audit_events" ADD CONSTRAINT "audit_events_company_id_organization_id_fk" FOREIGN KEY ("company_id") REFERENCES "auth"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "app"."documents" ADD CONSTRAINT "documents_company_id_organization_id_fk" FOREIGN KEY ("company_id") REFERENCES "auth"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "app"."audit_events" ADD CONSTRAINT "audit_events_company_id_organization_id_fk" FOREIGN KEY ("company_id") REFERENCES "identity"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "app"."documents" ADD CONSTRAINT "documents_company_id_organization_id_fk" FOREIGN KEY ("company_id") REFERENCES "identity"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "app"."documents" ADD CONSTRAINT "documents_request_id_requests_id_fk" FOREIGN KEY ("request_id") REFERENCES "app"."requests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "audit_events_entity_idx" ON "app"."audit_events" USING btree ("company_id","entity_type","entity_id");--> statement-breakpoint
CREATE INDEX "documents_company_id_idx" ON "app"."documents" USING btree ("company_id");--> statement-breakpoint
Expand Down
2 changes: 1 addition & 1 deletion src/db/migrations/0006_processing.sql
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ ALTER TABLE "app"."requests" ADD COLUMN "error_message" text;--> statement-break
ALTER TABLE "app"."requests" ADD COLUMN "attempts" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "app"."requests" ADD COLUMN "next_retry_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "app"."extracted_fields" ADD CONSTRAINT "extracted_fields_run_same_company_fk" FOREIGN KEY ("run_id","company_id") REFERENCES "app"."extraction_runs"("id","company_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "app"."extraction_runs" ADD CONSTRAINT "extraction_runs_company_id_organization_id_fk" FOREIGN KEY ("company_id") REFERENCES "auth"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "app"."extraction_runs" ADD CONSTRAINT "extraction_runs_company_id_organization_id_fk" FOREIGN KEY ("company_id") REFERENCES "identity"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "app"."extraction_runs" ADD CONSTRAINT "extraction_runs_request_same_company_fk" FOREIGN KEY ("request_id","company_id") REFERENCES "app"."requests"("id","company_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "app"."extraction_segments" ADD CONSTRAINT "extraction_segments_run_same_company_fk" FOREIGN KEY ("run_id","company_id") REFERENCES "app"."extraction_runs"("id","company_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "extracted_fields_request_idx" ON "app"."extracted_fields" USING btree ("company_id","request_id");--> statement-breakpoint
Expand Down
Loading
Loading