diff --git a/Architecture.md b/Architecture.md
new file mode 100644
index 0000000..3090c32
--- /dev/null
+++ b/Architecture.md
@@ -0,0 +1,525 @@
+# Architecture
+
+**Status:** Draft v1
+**Date:** 2026-06-24
+**Owner:** Patrick (plate-software)
+
+---
+
+## 1. Two artifacts, one contract
+
+plate-auth ships as **two coordinated artifacts** that share a single wire contract (the HMAC-signed
+exchange envelope + the JWT shape).
+
+```mermaid
+flowchart LR
+ subgraph Backend["plate-auth-starter (Maven)"]
+ BE[de.platesoft.auth.*
Spring Boot 4 auto-config]
+ end
+ subgraph Frontend["@platesoft/auth (npm)"]
+ FE[NextAuth v5 wiring +
exchange + proxy helpers]
+ end
+ Wire[(Wire contract:
POST /api/auth/exchange
HMAC-SHA256 envelope
JWT bearer)]
+ FE -.signs.-> Wire
+ BE -.verifies.-> Wire
+```
+
+The wire contract is the **only thing the two artifacts know about each other**. Either side can be
+re-implemented in another language as long as the contract is honored.
+
+---
+
+## 2. Tier model — what is in, what is out
+
+We split InspectFlow's auth/membership system into three tiers. plate-auth ships **T1 + T2 only**.
+
+```mermaid
+flowchart TB
+ subgraph T1["T1 — Auth Core (plate-auth-starter)"]
+ T1a[User + UserIdentity entities]
+ T1b[JwtAuthenticationFilter]
+ T1c[JwtService]
+ T1d[SecurityConfig auto-config]
+ T1e[OAuthController + ExchangeService]
+ T1f[AuthController login/register/refresh/me]
+ T1g[LoginEventService + revinfo actor]
+ end
+ subgraph T2["T2 — Multi-Tenancy (plate-auth-starter)"]
+ T2a[Membership entity + service]
+ T2b[Invitation entity + service + controller]
+ T2c[AccessRequest entity + service + controller]
+ T2d[OrgContextResolver]
+ T2e[AdminAuditController]
+ end
+ subgraph T3["T3 — Stays in consuming app"]
+ T3a[Company / Workspace / Club
concrete org entity]
+ T3b[OnboardingService
business-state machine]
+ T3c[TenantAutoMapService
MS-tenant → company mapping]
+ T3d[Org-specific embeddings,
industry codes, etc.]
+ end
+
+ T2 -.references via FK.-> T3
+ T2 -.OrgContextResolver hook.-> T3
+```
+
+**Why this cut:**
+
+- **T1** has zero domain coupling — works for any product.
+- **T2** is "user × org × role" — also generic, but its FK target (`org_id`) is per-product.
+- **T3** is product-specific business logic. It would constrain consumers if we extracted it.
+
+The seam between T2 and T3 is an **`OrgValidator` SPI** (Service Provider Interface) that the consuming app
+implements:
+
+```java
+public interface OrgValidator {
+ boolean exists(OrgType type, UUID orgId);
+ String displayName(OrgType type, UUID orgId);
+}
+```
+
+plate-auth's `MembershipService.grant(…)` calls `orgValidator.exists(…)` instead of joining to a `companies`
+table directly. This is the only generic-↔-domain coupling point.
+
+---
+
+## 3. Public API surface (Spring Boot starter)
+
+What a consumer can call / configure / override. Everything else is `package-private` or `@Internal`.
+
+### 3.1 HTTP endpoints (mounted under `/api/auth/**` and `/api/`)
+
+| Endpoint | Method | Public? | Purpose |
+|---|---|---|---|
+| `/api/auth/exchange` | POST | Public (HMAC-signed) | NextAuth → backend session bootstrap |
+| `/api/auth/register` | POST | Public (if `plate.auth.registration.enabled=true`) | Password registration |
+| `/api/auth/login` | POST | Public | Password login |
+| `/api/auth/refresh` | POST | Public | Refresh token rotation |
+| `/api/auth/logout` | POST | Public | Refresh token revocation |
+| `/api/auth/me` | GET | Authenticated | Current user + memberships |
+| `/api/auth/config` | GET | Public | Provider list + registration flag |
+| `/api/invitations` | CRUD | Authenticated (admin) | Invite management |
+| `/api/invitations/accept` | POST | Authenticated | Accept invite |
+| `/api/access-requests` | CRUD | Authenticated (admin) | Access request management |
+| `/api/admin/login-events` | GET | `hasRole('ADMIN')` | Audit log |
+| `/api/admin/audit/revisions` | GET | `hasRole('ADMIN')` | Envers revision browser |
+
+### 3.2 Spring beans the consumer can inject
+
+| Bean | Purpose |
+|---|---|
+| [`JwtService`](Architecture.md:62) | Generate / parse JWTs. Issue tokens for arbitrary `User`. |
+| `OAuthService` | Find-or-create User+Identity from exchange payload. |
+| `MembershipService` | Grant / revoke / list memberships. |
+| `InvitationService` | Create / accept / revoke invitations. |
+| `AccessRequestService` | Submit / approve / deny access requests. |
+| `LoginEventService` | Record login attempts (async). |
+
+### 3.3 Configuration properties (`plate.auth.*`)
+
+```yaml
+plate:
+ auth:
+ jwt:
+ secret: ${PLATE_AUTH_JWT_SECRET} # ≥32 chars, HMAC-SHA256 key
+ access-expiration: PT15M # ISO-8601 duration
+ refresh-expiration: P30D
+ issuer: plate-auth
+ exchange:
+ secret: ${PLATE_AUTH_EXCHANGE_SECRET} # ≥32 chars, shared with frontend
+ max-age: PT60S # envelope freshness window
+ nonce-ttl: PT5M # replay-protection window
+ registration:
+ enabled: false # password registration on/off
+ cors:
+ allowed-origins: # space-separated or list
+ - https://app.example.com
+ providers:
+ google:
+ enabled: true
+ microsoft:
+ enabled: false # Entra ID — opt-in
+ email-magic-link:
+ enabled: false # NextAuth Email provider
+```
+
+### 3.4 Extension points (SPI)
+
+Consumers implement these to plug their domain into plate-auth:
+
+| Interface | Required? | Default | Purpose |
+|---|---|---|---|
+| `OrgValidator` | **Yes** (if T2 used) | — | Validate `org_id` exists in consumer's `companies` / `workspaces` / etc. |
+| `OrgDisplayNameResolver` | Optional | Returns `org_id.toString()` | Pretty-print org for invitations / emails. |
+| `InvitationMailer` | Optional | No-op logger | Send invitation emails. Default just logs the token URL. |
+| `AccessRequestMailer` | Optional | No-op logger | Notify admins of new access requests. |
+| `OnboardingHook` | Optional | No-op | Called on first successful sign-in — consumer's place to wire T3 onboarding. |
+
+All extension points are `@ConditionalOnMissingBean` — register your own bean to override.
+
+---
+
+## 4. Public API surface (npm package)
+
+### 4.1 Exports
+
+```ts
+// @platesoft/auth/config
+export function createAuthConfig(opts: PlateAuthConfigOptions): NextAuthConfig;
+
+// @platesoft/auth/exchange
+export async function exchangeWithBackend(envelope: ExchangeEnvelope): Promise;
+export function signEnvelope(envelope: object, secret: string): { signature: string; envelope: string };
+
+// @platesoft/auth/proxy
+export function createProxyHandlers(opts: ProxyOptions): {
+ GET: RouteHandler; POST: RouteHandler; PUT: RouteHandler; PATCH: RouteHandler; DELETE: RouteHandler;
+};
+
+// @platesoft/auth/middleware
+export function createAuthMiddleware(opts?: MiddlewareOptions): NextMiddleware;
+
+// @platesoft/auth/client
+export { useSession, signIn, signOut } from 'next-auth/react'; // re-export
+export function useAccessToken(): string | null;
+export function useMemberships(): Membership[];
+```
+
+### 4.2 Required env vars
+
+```bash
+# Frontend (Next.js)
+NEXTAUTH_SECRET=... # NextAuth session secret
+NEXTAUTH_EXCHANGE_SECRET=... # MUST match backend plate.auth.exchange.secret
+NEXT_PUBLIC_BACKEND_URL=https://api.example.com
+GOOGLE_CLIENT_ID=...
+GOOGLE_CLIENT_SECRET=...
+# Optional
+MICROSOFT_ENTRA_CLIENT_ID=...
+MICROSOFT_ENTRA_CLIENT_SECRET=...
+MICROSOFT_ENTRA_TENANT_ID=common
+EMAIL_SERVER=... # only if magic-link enabled
+EMAIL_FROM=...
+```
+
+---
+
+## 5. End-to-end sign-in flow
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor U as User
+ participant FE as Browser (Next.js)
+ participant NA as NextAuth (/api/auth/[...nextauth])
+ participant G as Google
+ participant BE as Spring Boot (plate-auth)
+ participant DB as Postgres
+
+ U->>FE: click "Sign in with Google"
+ FE->>NA: signIn('google')
+ NA->>G: redirect to Google OAuth
+ G-->>NA: callback with id_token
+ NA->>NA: signIn callback fires
+ NA->>NA: build ExchangeEnvelope
{provider, providerSubject, email, name, nonce, iat}
+ NA->>NA: sign envelope with HMAC-SHA256(NEXTAUTH_EXCHANGE_SECRET)
+ NA->>BE: POST /api/auth/exchange
X-Exchange-Signature:
body: envelope
+ BE->>BE: ExchangeService.verifyAndParse()
(HMAC verify, max-age 60s, nonce dedup)
+ BE->>DB: SELECT user_identity WHERE provider+subject
+ alt Identity exists
+ DB-->>BE: User row
+ else New identity
+ BE->>DB: INSERT user + user_identity
+ BE->>BE: OnboardingHook.onFirstSignIn(user)
+ end
+ BE->>DB: INSERT login_event (success)
+ BE-->>NA: 200 {access_token, refresh_token, user, memberships}
+ NA->>NA: store tokens in NextAuth JWT
+ NA-->>FE: set session cookie
+ FE-->>U: redirect to app
+```
+
+Key invariants:
+
+- **Step 5 (signIn callback)** is where NextAuth's "I authenticated this user" claim becomes plate-auth's
+ "I issue tokens for this user" — bridged by the HMAC envelope.
+- **Step 7 (HMAC verify)** is the only thing preventing a malicious caller from minting tokens for any
+ Google account.
+- **Step 8 (nonce dedup)** is the only thing preventing replay of a captured envelope within its 60s
+ freshness window.
+- **Step 13 (`OnboardingHook`)** is the T3 escape hatch — consumers wire whatever first-login flow they need.
+
+---
+
+## 6. Request → API flow (authenticated)
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor U as User
+ participant FE as Browser
+ participant PR as Next.js /api/[...path] proxy
+ participant NA as NextAuth auth()
+ participant BE as Spring Boot
+ participant JF as JwtAuthFilter
+ participant OR as OrgContextResolver
+ participant CT as @RestController
+
+ U->>FE: action triggers fetch /api/things
+ FE->>PR: GET /api/things (no auth header — session cookie only)
+ PR->>NA: auth() — read session
+ NA-->>PR: {accessToken, user}
+ PR->>BE: GET /api/things
Authorization: Bearer
X-Org-Id: (optional)
+ BE->>JF: JwtAuthFilter.doFilter()
+ JF->>JF: extract + verify JWT (HMAC SHA-256, issuer check)
+ JF->>JF: set SecurityContext (principal = email, authorities = [ROLE_])
+ JF->>OR: OrgContextResolver.resolve()
+ OR->>OR: parse X-Org-Id, validate membership via MembershipService
+ OR->>OR: set OrgContext threadlocal
+ OR->>CT: forward to controller
+ CT-->>BE: response
+ BE-->>PR: 200 response (or 401/403)
+ PR-->>FE: passthrough response
+```
+
+The proxy pattern (step 2-6) is the key design choice: **the browser never sees the JWT directly**. The
+JWT lives in the encrypted NextAuth session cookie; the proxy adds the `Authorization` header server-side
+before forwarding. This sidesteps XSS token theft.
+
+---
+
+## 7. Data model
+
+```mermaid
+erDiagram
+ USERS ||--o{ USER_IDENTITIES : "1:N"
+ USERS ||--o{ MEMBERSHIPS : "1:N"
+ USERS ||--o{ INVITATIONS : "created_by"
+ USERS ||--o{ ACCESS_REQUESTS : "requested_by"
+ USERS ||--o{ LOGIN_EVENTS : "actor"
+ MEMBERSHIPS }o..|| ORG_TABLE : "(org_type, org_id)
polymorphic FK"
+ INVITATIONS }o..|| ORG_TABLE : "polymorphic"
+ ACCESS_REQUESTS }o..|| ORG_TABLE : "polymorphic"
+
+ USERS {
+ uuid id PK
+ text email UK
+ text password_hash "nullable for OAuth-only"
+ text first_name
+ text last_name
+ text role "ROLE_USER | ROLE_ADMIN"
+ boolean active
+ uuid default_org_id "nullable"
+ text last_provider "google|microsoft|email|password"
+ bigint version "optimistic lock"
+ }
+ USER_IDENTITIES {
+ uuid id PK
+ uuid user_id FK
+ text provider "google|microsoft|email|password"
+ text subject "provider's stable user id"
+ text email
+ text tenant_id "MS Entra tenant, nullable"
+ timestamptz linked_at
+ timestamptz last_login_at
+ }
+ MEMBERSHIPS {
+ uuid id PK
+ uuid user_id FK
+ text org_type "COMPANY|WORKSPACE|CLUB|..."
+ uuid org_id "polymorphic — validated via OrgValidator SPI"
+ text role "OWNER|ADMIN|MEMBER|VIEWER"
+ text status "ACTIVE|SUSPENDED|REVOKED"
+ uuid granted_by FK
+ text grant_reason
+ timestamptz revoked_at
+ uuid revoked_by FK
+ }
+ INVITATIONS {
+ uuid id PK
+ text token UK "64-char single-use"
+ text email
+ text org_type
+ uuid org_id
+ text role
+ text status "PENDING|ACCEPTED|REVOKED|EXPIRED"
+ uuid created_by FK
+ timestamptz created_at
+ timestamptz expires_at
+ timestamptz accepted_at
+ }
+ ACCESS_REQUESTS {
+ uuid id PK
+ uuid requester_id FK
+ text org_type
+ uuid org_id
+ text requested_role "default VIEWER"
+ text justification
+ text status "PENDING|APPROVED|DENIED|EXPIRED"
+ uuid reviewer_id FK
+ text decision_reason
+ }
+ LOGIN_EVENTS {
+ bigint id PK
+ uuid user_id FK
+ text email "denormalized for failed attempts"
+ text provider
+ text outcome "SUCCESS|FAILURE|LOCKED"
+ text ip_address
+ text user_agent
+ text correlation_id
+ timestamptz occurred_at
+ }
+```
+
+**The polymorphic FK on `memberships.org_id`** is the only "loose" join in the schema. It is intentional:
+plate-auth must not require a specific concrete org table. Validation is enforced via:
+
+1. The `OrgValidator` SPI (application-level check at membership-grant time).
+2. The `fn_membership_org_fk()` trigger (consumer-defined — InspectFlow's trigger validates against
+ `companies`; Sparkboard's will validate against `workspaces`).
+
+---
+
+## 8. Package layout
+
+### 8.1 Maven module
+
+```
+plate-auth/
+├── plate-auth-starter/ # the published artifact
+│ ├── pom.xml # de.platesoft:plate-auth-starter:0.1.0
+│ └── src/main/java/de/platesoft/auth/
+│ ├── PlateAuthAutoConfiguration.java
+│ ├── PlateAuthProperties.java
+│ ├── config/
+│ │ └── SecurityConfig.java
+│ ├── filter/
+│ │ ├── JwtAuthenticationFilter.java
+│ │ └── OrgContextResolver.java
+│ ├── service/
+│ │ ├── JwtService.java
+│ │ ├── OAuthService.java
+│ │ ├── MembershipService.java
+│ │ ├── InvitationService.java
+│ │ ├── AccessRequestService.java
+│ │ ├── LoginEventService.java
+│ │ └── ExchangeService.java
+│ ├── controller/
+│ │ ├── AuthController.java
+│ │ ├── OAuthController.java
+│ │ ├── InvitationController.java
+│ │ ├── AccessRequestController.java
+│ │ └── AdminAuditController.java
+│ ├── entity/
+│ │ ├── User.java
+│ │ ├── UserIdentity.java
+│ │ ├── Membership.java
+│ │ ├── Invitation.java
+│ │ ├── AccessRequest.java
+│ │ └── LoginEvent.java
+│ ├── repository/...
+│ ├── dto/
+│ │ ├── request/...
+│ │ └── response/...
+│ ├── spi/
+│ │ ├── OrgValidator.java
+│ │ ├── OrgDisplayNameResolver.java
+│ │ ├── InvitationMailer.java
+│ │ ├── AccessRequestMailer.java
+│ │ └── OnboardingHook.java
+│ └── enums/
+│ ├── OrgType.java
+│ ├── MembershipRole.java
+│ ├── MembershipStatus.java
+│ ├── InvitationStatus.java
+│ ├── AccessRequestStatus.java
+│ ├── LoginProvider.java
+│ └── Role.java
+│ └── resources/
+│ ├── META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+│ └── db/migration/auth/
+│ ├── V1__create_users_and_identities.sql
+│ ├── V2__create_memberships.sql
+│ ├── V3__create_invitations.sql
+│ ├── V4__create_access_requests.sql
+│ ├── V5__add_microsoft_tenant_id_index.sql
+│ └── V6__create_login_events_and_revinfo_actor.sql
+└── plate-auth-tests/ # internal integration tests, not published
+```
+
+### 8.2 npm package
+
+```
+@platesoft/auth/
+├── package.json # @platesoft/auth@0.1.0
+├── src/
+│ ├── config/
+│ │ ├── index.ts # createAuthConfig()
+│ │ ├── providers/
+│ │ │ ├── google.ts
+│ │ │ ├── microsoft.ts
+│ │ │ └── email.ts
+│ │ └── callbacks/
+│ │ ├── signIn.ts # exchange-with-backend signIn
+│ │ └── jwt.ts
+│ ├── exchange/
+│ │ ├── envelope.ts # signEnvelope, verifyEnvelope (Edge-compatible HMAC)
+│ │ └── client.ts # exchangeWithBackend
+│ ├── proxy/
+│ │ └── handlers.ts # createProxyHandlers
+│ ├── middleware/
+│ │ └── index.ts # createAuthMiddleware
+│ └── client/
+│ ├── hooks.ts # useAccessToken, useMemberships
+│ └── index.ts # re-exports from next-auth/react
+└── tests/
+```
+
+---
+
+## 9. Distribution
+
+```mermaid
+flowchart LR
+ Source[plate-auth git repo
git.plate-software.de/pplate/plate-auth] --> CI{Gitea Actions}
+ CI -->|tag v0.x.y| MavenReg[Gitea Package Registry
Maven]
+ CI -->|tag v0.x.y| NpmReg[Gitea Package Registry
npm]
+ MavenReg --> IF1[InspectFlow backend]
+ MavenReg --> SB1[Sparkboard backend]
+ NpmReg --> IF2[InspectFlow frontend]
+ NpmReg --> SB2[Sparkboard frontend]
+```
+
+- Single git repo, two artifact pipelines.
+- Tag `v0.1.0` triggers publish of both `de.platesoft:plate-auth-starter:0.1.0` and `@platesoft/auth@0.1.0`.
+ **Versions stay locked in lockstep** — frontend 0.2.0 implies backend 0.2.0 is the required peer.
+- Gitea Package Registry is the only distribution channel. No public registry.
+
+---
+
+## 10. Threat model summary
+
+| Threat | Mitigation |
+|---|---|
+| Forged exchange envelope | HMAC-SHA256 with ≥32-char shared secret, constant-time compare |
+| Replay of captured envelope | 60s `max-age` window + 5min nonce dedup store |
+| JWT theft via XSS | Token never sent to browser — proxy injects it server-side |
+| JWT forgery | HMAC-SHA256, ≥32-char secret, issuer + expiration validation |
+| Membership escalation | Rank-based `MembershipService.grant()` — cannot grant higher role than your own |
+| Invitation token enumeration | 64-char URL-safe random, single-use, expires after 7d (configurable) |
+| Access request DoS | Rate-limit 3 per user per day |
+| Replay across requests | JWT is short-lived (15min); refresh tokens are rotated |
+| Audit gap | All identity-changing ops emit revinfo with `actor_user_id`; failed logins emit `login_event` |
+
+Full threat model lives in `Sprint-0-Plan.md` § "Security review checklist".
+
+---
+
+## 11. What this document is not
+
+- ❌ Not a step-by-step extraction plan → see [`Sprint-0-Plan.md`](Sprint-0-Plan.md)
+- ❌ Not a per-API specification → generated from JavaDoc / TSDoc at v0.2
+- ❌ Not the threat model in full → see Sprint-0-Plan § Security
+
+It is the **shape** of the system. The rest of the wiki fills in the pixels.