diff --git a/Sprint-1-Assessment.md b/Sprint-1-Assessment.md
new file mode 100644
index 0000000..f70b213
--- /dev/null
+++ b/Sprint-1-Assessment.md
@@ -0,0 +1,253 @@
+# Sprint 1 — Assessment
+
+**Status:** Draft v1
+**Date:** 2026-06-24
+**Owner:** Patrick (plate-software)
+**Purpose:** Assess what v0.1 delivered, predict what consumers (InspectFlow + Sparkboard) will
+need in their first month, classify each v0.2 feature by priority, and surface the risks of the
+polish sprint. This document is the basis for [`Sprint-1-Plan.md`](Sprint-1-Plan.md).
+
+---
+
+## 1. v0.1 baseline — what shipped
+
+Sprint 0 (W1–W12) extracted InspectFlow's auth/membership system into a reusable two-artifact
+library. The table below is the *delivered* surface at the `v0.1.0` tag, cross-referenced to the
+[Architecture](Architecture.md) tiers.
+
+### 1.1 Backend — `de.platesoft:plate-auth-starter:0.1.0`
+
+| Layer | Delivered | Reference |
+|---|---|---|
+| Auto-config | `PlateAuthAutoConfiguration` (explicit `@Import`, no `@ComponentScan`) + `PlateAuthProperties` (`plate.auth.*`) + `META-INF/spring/...AutoConfiguration.imports` | [Architecture §3](Architecture.md) |
+| Security | `SecurityConfig` scoped via `securityMatcher(...)` at `@Order(100)` — only plate-auth endpoints | Q13 (decided) |
+| Filter | `JwtAuthenticationFilter`, `OrgContextResolver` | [Architecture §5–6](Architecture.md) |
+| Service | `JwtService`, `ExchangeService`, `OAuthService`, `MembershipService`, `InvitationService`, `AccessRequestService`, `LoginEventService` | [Architecture §3.2](Architecture.md) |
+| Controller | `OAuthController`, `AuthController`, `InvitationController`, `AccessRequestController`, `AdminAuditController` | [Architecture §3.1](Architecture.md) |
+| Entity | `User`, `UserIdentity`, `Membership`, `Invitation`, `AccessRequest`, `LoginEvent`, `RevInfo` | [Architecture §7](Architecture.md) |
+| SPI | `OrgValidator`, `OrgDisplayNameResolver`, `InvitationMailer`, `AccessRequestMailer`, `OnboardingHook` (5 interfaces + 5 no-op/defaults via `@ConditionalOnMissingBean`) | [Architecture §3.4](Architecture.md) |
+| Migration | Flyway V1–V6 under `classpath:db/migration/auth/` + separate `flyway_schema_history_auth` table | [Architecture §8.1](Architecture.md) |
+| Wire contract | `WIRE_VERSION=1` constant in exchange envelope, backend rejects mismatch | Q06 (decided) |
+| Provider | Google OAuth (enabled by default); Microsoft + email toggles exist but are **off** | Q02/Q04 (deferred) |
+
+### 1.2 Frontend — `@platesoft/auth@0.1.0`
+
+| Module | Delivered | Reference |
+|---|---|---|
+| `config/` | `createAuthConfig(opts)` factory → NextAuth v5 config with Google provider | [Architecture §4.1](Architecture.md) |
+| `exchange/` | `signEnvelope()` / `verifyEnvelope()` using Web Crypto (Edge-safe) + `exchangeWithBackend()` | [Architecture §4.1](Architecture.md) |
+| `proxy/` | `createProxyHandlers(opts)` — `auth()` → Bearer injection + hop-by-hop strip + `duplex:"half"` | [Architecture §4.1](Architecture.md) |
+| `middleware/` | `createAuthMiddleware(opts)` with configurable `publicPaths` | [Architecture §4.1](Architecture.md) |
+| `client/` | `useAccessToken()`, `useMemberships()`, re-export of `useSession`/`signIn`/`signOut` | [Architecture §4.1](Architecture.md) |
+| Build | `tsup` dual ESM/CJS + `.d.ts` | Q09 (decided) |
+
+### 1.3 What v0.1 explicitly left behind (deferred backlog)
+
+These items were tracked in [Sprint-0-Plan §11](Sprint-0-Plan.md) and now form the v0.2 input
+backlog. Each was a *deliberate* deferral, not an oversight:
+
+| # | Deferred item | Deferred because | Target |
+|---|---|---|---|
+| D1 | MS Entra ID provider | Extraction discipline — never in InspectFlow 14.x | v0.2 |
+| D2 | Email magic-link provider | Same — InspectFlow 14.1 deferred it | v0.2 |
+| D3 | `LoginEventSink` SPI | DB rows + Envers sufficient for 2 consumers | v0.2 |
+| D4 | Real `InvitationMailer` (JavaMailSender) | No-op logger is enough to extract; real mail is polish | v0.2 |
+| D5 | `AccessRequestMailer` template | Same | v0.2 |
+| D6 | Configurable invitation expiration | Hardcoded 7d is fine for extraction | v0.2 |
+| D7 | RFC 7807 Problem Details | v0.1 errors are plain Spring Boot defaults | v0.2 |
+| D8 | TS type exports (`Membership`, `Invitation`, `AccessRequest`) | Consumers can cast in v0.1 | v0.2 |
+| D9 | Zod schemas for envelope + DTOs | Nice-to-have | v0.2 |
+| D10 | Edge-runtime safe `useAccessToken()` | Current uses `getSession()` (App-Router-only) | v0.2 |
+| D11 | Refresh-token rotation hardening | Stateless JWT refresh accepted for v0.1 | v0.3 (Roadmap) |
+
+---
+
+## 2. Consumer forecast — first-month needs
+
+The [Roadmap](Roadmap.md) says v0.2 is "triggered by feedback from consumers during their first
+month using v0.1." We cannot wait for that feedback to plan — Sparkboard is greenfield and will hit
+its needs on day 1. This section predicts the pain points based on what each consumer *is*.
+
+```mermaid
+flowchart LR
+ V01[v0.1.0 ships] --> IF[InspectFlow migrates
~1 week]
+ V01 --> SB[Sparkboard adopts
~3 weeks]
+ IF --> F1[Feedback wave 1
operational pain]
+ SB --> F2[Feedback wave 2
greenfield friction]
+ F1 --> V02[v0.2 backlog
confirmed/adjusted]
+ F2 --> V02
+```
+
+### 2.1 InspectFlow — operational pain (week 1–2)
+
+InspectFlow is migrating *existing* users from in-tree auth onto the library. Its pain points are
+operational, not greenfield:
+
+| Predicted pain | Why | v0.2 feature that fixes it |
+|---|---|---|
+| **MS Entra ID sign-in stops working** | InspectFlow's V30 migration + `TenantAutoMapService` handled MS tenants. plate-auth v0.1 ships no MS provider — InspectFlow users with `provider=microsoft` identities will have broken sign-in until v0.2. | **W1 — MS Entra ID provider** |
+| **Invitation emails silently fail** | v0.1's `LoggingInvitationMailer` logs the URL instead of sending mail. InspectFlow admins who invite users will think the invite sent, but nothing arrives. | **W3 — Real `InvitationMailer`** |
+| **Error responses are opaque** | v0.1 returns Spring Boot's default JSON error body. InspectFlow's frontend has no structured way to distinguish "expired token" from "invalid credentials." | **W4 — RFC 7807 Problem Details** |
+| **Invitations expire too fast / too slow** | InspectFlow's original 7d window is hardcoded. If they want 3d or 14d, they must fork. | **W4 — Configurable expiration** |
+
+### 2.2 Sparkboard — greenfield friction (week 1–3)
+
+Sparkboard starts from zero. Its pain is *developer experience* friction — things that are possible
+but require more boilerplate than they should:
+
+| Predicted pain | Why | v0.2 feature that fixes it |
+|---|---|---|
+| **No email sign-in option** | Sparkboard is a 4-person family app ("Family Spark"). Google-only auth is fine for Patrick, but his friend's sons may not have Google accounts. Email magic-link is the natural second option. | **W2 — Email magic-link** |
+| **Edge middleware can't read access token** | `useAccessToken()` calls `getSession()` which is App-Router-only. If Sparkboard uses Edge-runtime middleware for route protection, the hook throws. | **W5 — Edge-runtime safety** |
+| **TypeScript types are loose** | Sparkboard's frontend gets `any` from `useMemberships()` because `Membership` isn't exported. Runtime works, but DX is rough. | **W5 — TS improvements** |
+| **No email UX page** | Email magic-link requires a callback page. Consumers build it themselves in v0.1. | **W2 — default UX page** |
+| **Login events stuck in DB only** | Sparkboard may want to tee login events to a webhook or Loki for the homelab observability stack. | **W3 — `LoginEventSink` SPI** |
+
+### 2.3 Hypothesis validation
+
+Per [Roadmap § "Hypothesis-driven scoping"](Roadmap.md):
+
+> **v0.2 hypothesis:** The SPI is enough — no consumer needs to fork.
+
+We validate this by tracking whether InspectFlow or Sparkboard shadow-override plate-auth classes
+instead of using the SPI. If they fork, the next minor becomes a "why do they fork" investigation.
+v0.2 adds `LoginEventSink` as a **new** SPI specifically to prevent a fork around audit shipping.
+
+---
+
+## 3. v0.2 feature classification
+
+Each v0.2 candidate from the [Roadmap](Roadmap.md) and the [Open-Questions](Open-Questions.md)
+deferrals is classified into three tiers. Priority is driven by **consumer-blocking severity**, not
+effort.
+
+```mermaid
+flowchart TB
+ subgraph Must[Must-have — blocks a consumer]
+ M1[W1: MS Entra ID
blocks InspectFlow]
+ M2[W3: Real InvitationMailer
blocks invitations]
+ M3[W4: RFC 7807 + config expiration
blocks error UX]
+ end
+ subgraph Nice[Nice-to-have — smooths DX]
+ N1[W2: Email magic-link
Sparkboard option]
+ N2[W3: LoginEventSink SPI
audit shipping]
+ N3[W3: AccessRequestMailer template]
+ N4[W5: TS improvements
typed exports + Zod]
+ N5[W5: Edge-runtime safety]
+ end
+ subgraph Stretch[Stretch — if time allows]
+ S1[W6: Wire-version bump
if contract changes]
+ S2[WebAuthn / passkey]
+ S3[Per-app branding]
+ end
+```
+
+### 3.1 Must-have (blocks at least one consumer)
+
+| # | Feature | Blocks whom | Justification |
+|---|---|---|---|
+| **W1** | MS Entra ID provider | InspectFlow | InspectFlow has live `provider=microsoft` identities. Without the provider, those users cannot sign in post-migration. This is a **regression**, not a missing feature. The SPI hooks (`OnboardingHook`, V5 tenant-index migration) are ready — the provider implementation is the only gap. |
+| **W3** | Real `InvitationMailer` (JavaMailSender) | Both | v0.1's logging mailer means invitations silently fail in any non-dev environment. Any consumer that uses invitations (both do) hits this within week 1. |
+| **W4** | RFC 7807 Problem Details | Both | Consumers cannot build robust error UX on v0.1's opaque error bodies. This is the #1 frontend DX complaint we predict. |
+
+### 3.2 Nice-to-have (improves DX but has a workaround)
+
+| # | Feature | Workaround if skipped | Justification |
+|---|---|---|---|
+| **W2** | Email magic-link provider | Google-only auth | Sparkboard's family users *can* use Google. But the friend's sons may not have accounts — email magic-link is the natural fallback. Predicted to become must-have if Sparkboard gets a non-Google user. |
+| **W3** | `LoginEventSink` SPI | Query `login_events` table directly | DB rows work. The SPI lets consumers tee to Loki/SIEM without polling. Nice for homelab observability; not blocking. |
+| **W3** | `AccessRequestMailer` template | Same logging fallback as invitation | Less critical than invitation mailer (access requests are less frequent), but should ship together for symmetry. |
+| **W5** | TS improvements (typed exports + Zod) | Cast to `any` / manual types | Works, but rough DX. Zod schemas enable runtime validation on the frontend. Predicted to be the #2 frontend complaint. |
+| **W5** | Edge-runtime safe `useAccessToken()` | Don't use Edge middleware for auth | `useAccessToken()` currently throws in Edge runtime. Workaround: use Node middleware. But this is a footgun — should be fixed proactively. |
+
+### 3.3 Stretch (only if must-haves + nice-to-haves finish early)
+
+| # | Feature | Why stretch |
+|---|---|---|
+| **W6** | Wire-version bump to `WIRE_VERSION=2` | Only needed if the envelope contract actually changes (e.g., adding provider-specific fields). If the MS/email providers fit the existing `ExchangeEnvelope` shape, no bump needed. Conditional. |
+| — | WebAuthn / passkey provider | No consumer has asked. NextAuth supports it, but backend plumbing is non-trivial. Roadmap says "possibly v0.2." Defer unless a consumer explicitly requests. |
+| — | Per-app branding on exchange endpoint | Custom user-agent string for audit. Low value for a 2-consumer library. Defer. |
+
+### 3.4 Priority → workstream mapping
+
+| Priority | Workstream | Effort (GLM-5.2+Lumen) |
+|---|---|---|
+| Must-have | W1 (MS Entra), W3 (mailers), W4 (RFC 7807 + config) | ~3 days |
+| Nice-to-have | W2 (email), W3 (LoginEventSink), W5 (TS + Edge) | ~3 days |
+| Stretch | W6 (wire-version + IT + publish) | ~1 day |
+| **Total** | **W1–W6** | **~7 days** (fits 3 calendar weeks per Roadmap) |
+
+---
+
+## 4. Risk register
+
+| # | Risk | Likelihood | Impact | Mitigation |
+|---|---|---|---|---|
+| R1 | MS Entra ID provider doesn't match InspectFlow's existing `TenantAutoMapService` behavior | Medium | **High** — InspectFlow MS users locked out | W1 must replicate the tenant→org auto-mapping via `OnboardingHook`. Test against InspectFlow's seeded MS identities. The V5 tenant-index migration is already shipped — use it. |
+| R2 | Email magic-link introduces email enumeration vector | Medium | Medium | `allowDangerousEmailAccountLinking=false` (already enforced). Magic-link responses must be generic ("if the email exists, a link was sent") — no "user not found" leak. Test T-SEC11. |
+| R3 | `JavaMailSender` default fails silently when SMTP misconfigured | Medium | Medium | The real `InvitationMailer` must **fail loudly** — throw on mail-send failure, not swallow. Integration test with a mock SMTP (GreenMail or Testcontainers mail). Consumer must set `spring.mail.host`. |
+| R4 | RFC 7807 Problem Details breaks v0.1 consumers' error parsing | Low | Medium | Problem Details is *additive* (new `Content-Type: application/problem+json`). v0.1 consumers that parsed the old body will see a different shape. Document in CHANGELOG. If the wire-version changes, bump `WIRE_VERSION`. |
+| R5 | Edge-runtime safety fix changes `useAccessToken()` return semantics | Low | Low | The fix makes the hook work in Edge (reads from cookie/token-store instead of `getSession()`). Non-Edge consumers are unaffected. Snapshot test guards the contract. |
+| R6 | Wire-version bump to 2 forces a coordinated upgrade across both consumers | Medium (if triggered) | Medium | Per [versioning policy](Roadmap.md), wire-version change = MINOR bump (`0.2.0`). Both consumers upgrade lockstep. CHANGELOG documents the migration. |
+| R7 | `LoginEventSink` SPI fires synchronously and slows login | Low | Medium | SPI must be **async** (annotated `@Async` or dispatched to an executor). The default no-op sink has zero overhead. Test T-UT16 measures login latency with a sink attached. |
+| R8 | Zod schemas drift from backend DTOs | Low | Low | Generate Zod schemas from a single source (TS types) or use a contract test. The golden-vector approach from T-FE02 extends to schema validation. |
+| R9 | MS Entra ID `common` tenant vs single-tenant mismatch | Medium | Medium | Provider config must expose `tenant-id` as a property (`plate.auth.providers.microsoft.tenant-id`). Default `common` (multi-tenant). InspectFlow may pin to their tenant. Document in [Integration-Guide.md](Integration-Guide.md). |
+| R10 | Email magic-link token replay | Low | Medium | NextAuth Email provider generates single-use HMAC'd magic links. Backend `ExchangeService` already has nonce dedup. The magic-link flow goes through the same exchange envelope — reuse existing replay protection. |
+| R11 | Configurable invitation expiration = 0 or negative breaks invitation creation | Low | Low | Bean-validate `plate.auth.invitation.expiration-days` with `@Min(1) @Max(90)`. Fails fast at startup if out of range. |
+| R12 | v0.1 ships late, compressing v0.2 timeline | Medium | Medium | v0.2 is "as priorities allow" per [Roadmap](Roadmap.md). Must-haves (W1/W3/W4) can ship as v0.2.0 even if nice-to-haves slip to v0.2.1. No hard deadline except consumer pressure. |
+
+---
+
+## 5. Dependencies we are accepting
+
+v0.2 adds these dependencies on top of v0.1's stack ([Sprint-0-Assessment §4](Sprint-0-Assessment.md)):
+
+| Dep | Version | Added by | Notes |
+|---|---|---|---|
+| `spring-boot-starter-mail` | (Spring Boot managed) | W3 (real mailers) | Brings `JavaMailSender`. Already listed as a dep in v0.1's pom but unused — now activated. |
+| `next-auth` Email provider | 5.x (beta tracking) | W2 (magic-link) | Part of NextAuth v5, no new package. Requires `nodemailer` or SMTP config on the consumer side. |
+| `zod` | ^3.x | W5 (TS schemas) | Runtime validation. Ships as a **dependency** of `@platesoft/auth`, not a peer dep (consumers get it automatically). |
+
+No new Spring Boot / Java / Postgres version changes. v0.2 stays on the v0.1 baseline.
+
+---
+
+## 6. Recommendation
+
+**GO** — proceed to [`Sprint-1-Plan.md`](Sprint-1-Plan.md) with the following constraints:
+
+1. **W1 (MS Entra ID) is the critical path.** It is the only must-have that fixes a *regression*
+ (broken sign-in for existing InspectFlow MS users). It must land first and be tested against
+ InspectFlow's seeded MS identities.
+2. **W3 (mailers) and W4 (RFC 7807 + config) are must-haves but lower-risk.** They can proceed in
+ parallel with W1.
+3. **Nice-to-haves (W2, W5) are planned but not promised.** If v0.1 consumer feedback redirects
+ priorities, these can slip to v0.2.1 without blocking the v0.2.0 release.
+4. **Wire-version bump (W6) is conditional.** Only bump `WIRE_VERSION` if the envelope contract
+ actually changes. If MS/email providers fit the existing shape, `WIRE_VERSION` stays at 1 and
+ v0.2.0 is a pure additive release.
+5. **WebAuthn + per-app branding stay deferred.** No consumer has asked. Revisit at v0.3.
+6. **No new Flyway migrations in v0.2.** The V1–V6 schema is sufficient. If MS Entra needs a new
+ column (e.g., `microsoft_graph_id`), add V7 — but only if the existing `user_identities.subject`
+ field is insufficient. Predicted: no new migration needed.
+
+The Roadmap says v0.2 is "triggered by feedback." This assessment plans proactively so the
+must-haves are ready the moment v0.1 ships. Consumer feedback adjusts the nice-to-have / stretch
+split, not the must-have core.
+
+---
+
+## 7. Cross-references
+
+- [Roadmap.md](Roadmap.md) — v0.2 section + hypothesis-driven scoping
+- [Architecture.md](Architecture.md) — SPI model, wire contract, threat model
+- [Open-Questions.md](Open-Questions.md) — Q02 (MS Entra deferred), Q04 (email deferred), Q12 (audit DB-only)
+- [Sprint-0-Plan.md](Sprint-0-Plan.md) — §11 deferred items (v0.2 backlog source)
+- [Sprint-0-Assessment.md](Sprint-0-Assessment.md) — format reference + v0.1 source inventory
+- [Sprint-1-Plan.md](Sprint-1-Plan.md) — implementation plan based on this assessment
+- [Sprint-1-Testplan.md](Sprint-1-Testplan.md) — test matrix for v0.2
+
+---
+
+*Assessment ready for Plan Reviewer. Sprint-1-Plan v1 will be drafted assuming all Section 6
+recommendations hold; any Plan Reviewer pushback re-opens the priority split.*
diff --git a/Sprint-1-Plan.md b/Sprint-1-Plan.md
new file mode 100644
index 0000000..bdbc73f
--- /dev/null
+++ b/Sprint-1-Plan.md
@@ -0,0 +1,750 @@
+# Sprint 1 — Implementation Plan
+
+**Status:** Draft v1
+**Date:** 2026-06-24
+**Owner:** Patrick (plate-software)
+**Based on:** [`Sprint-1-Assessment.md`](Sprint-1-Assessment.md), [`Architecture.md`](Architecture.md), [`Roadmap.md`](Roadmap.md) v0.2 section
+**Target version:** `0.2.0` (both Maven + npm, lockstep)
+**Theme:** *Polish — make the consumer experience pleasant*
+
+---
+
+## Reading guide
+
+This plan follows the same structure as [`Sprint-0-Plan.md`](Sprint-0-Plan.md):
+
+1. **Scope + ground rules** (this section)
+2. **Workstream overview** (6 workstreams, W1–W6)
+3. **Step-by-step per workstream** (goal, steps, done-when)
+4. **Security review checklist additions** (incremental over v0.1's §9)
+5. **Rollout + acceptance criteria**
+
+Each step is numbered so the Code-mode worker can check off progress without ambiguity.
+
+---
+
+## 1. Scope + ground rules
+
+### 1.1 In scope (Sprint 1 / v0.2.0)
+
+- **W1** — MS Entra ID provider (backend OAuth + frontend NextAuth provider)
+- **W2** — Email magic-link provider (backend verification + frontend NextAuth Email provider + default UX page)
+- **W3** — `LoginEventSink` SPI + real `InvitationMailer` (JavaMailSender) + `AccessRequestMailer` admin template
+- **W4** — RFC 7807 Problem Details on `/api/auth/*` + configurable invitation expiration
+- **W5** — TypeScript improvements (typed exports, Zod schemas) + Edge-runtime safe `useAccessToken()`
+- **W6** — Wire-version assessment + integration tests + publish `v0.2.0`
+
+### 1.2 Out of scope (deferred — see [Roadmap](Roadmap.md))
+
+- Multi-replica nonce store (v0.3)
+- Refresh-token rotation table (v0.3)
+- JWT secret rotation via `kid` (v0.3)
+- WebAuthn / passkeys (possibly v0.2 — see §1.3)
+- Per-app branding on exchange endpoint (possibly v0.2 — see §1.3)
+- SAML, SCIM, OIDC server, mobile SDKs (post-1.0)
+
+### 1.3 Stretch (only if must-haves + nice-to-haves finish early)
+
+- WebAuthn / passkey provider — no consumer has asked; defer unless explicitly requested
+- Per-app branding — low value for a 2-consumer library; defer
+- ADR-style docs auto-published to wiki
+
+### 1.4 Ground rules
+
+- **v0.2 is additive.** No removal of v0.1 public API. Any breaking change is documented in
+ CHANGELOG with a migration recipe (per [deprecation policy](Roadmap.md)).
+- **Lockstep versions.** `@platesoft/auth@0.2.0` and `de.platesoft:plate-auth-starter:0.2.0` ship
+ together from the same `v0.2.0` git tag.
+- **Wire-version discipline.** If the exchange envelope contract changes, bump `WIRE_VERSION` to 2.
+ If it doesn't (providers fit existing shape), `WIRE_VERSION` stays at 1 and v0.2 is purely additive.
+- **Branch policy:** `feature/sprint-1/` branches → PR → squash-merge to `main`.
+- **GLM-5.2 + Lumen working model.** Effort estimates reflect AI-assisted speed — the provider
+ implementations follow the same OAuth pattern as Google (already proven in v0.1).
+
+---
+
+## 2. Workstream overview
+
+```mermaid
+flowchart LR
+ W1[W1: MS Entra ID provider] --> W6[W6: Wire-version
+ IT + publish]
+ W2[W2: Email magic-link] --> W6
+ W3[W3: LoginEventSink SPI
+ real mailers] --> W6
+ W4[W4: RFC 7807 Problem Details
+ config expiration] --> W6
+ W5[W5: TS improvements
+ Edge safety] --> W6
+ W6 --> TAG[v0.2.0 tag]
+```
+
+| # | Workstream | Priority | Depends on | Est. (AI-assisted) |
+|---|---|---|---|---|
+| W1 | MS Entra ID provider (backend + frontend) | **Must-have** | v0.1 shipped | ~1 day |
+| W2 | Email magic-link provider | Nice-to-have | v0.1 shipped | ~1 day |
+| W3 | LoginEventSink SPI + real mailers | **Must-have** (mailers) / Nice (SPI) | v0.1 shipped | ~1.5 days |
+| W4 | RFC 7807 + configurable expiration | **Must-have** | v0.1 shipped | ~0.5 days |
+| W5 | TS improvements + Edge safety | Nice-to-have | v0.1 shipped | ~1 day |
+| W6 | Wire-version + IT + publish v0.2.0 | Required | W1–W5 | ~1 day |
+| | **Total** | | | **~6 days** |
+
+W1–W5 are parallelizable. W6 is the integration + publish gate.
+
+---
+
+## 3. W1 — MS Entra ID provider
+
+**Goal:** Add Microsoft Entra ID as a first-class OAuth provider, symmetric with Google. This fixes
+the regression where InspectFlow's existing `provider=microsoft` identities have no sign-in path in
+v0.1.
+
+**Branch:** `feature/sprint-1/w1-ms-entra`
+
+### 3.1 Backend
+
+**Steps:**
+
+1. **W1-1** Create `service/MicrosoftEntraService.java` (or extend `OAuthService`) to handle MS
+ Entra ID token verification. Same pattern as Google:
+ - Accept `id_token` from the frontend exchange envelope
+ - Verify signature against MS Entra JWKS endpoint
+ - Extract `oid` (Microsoft's stable user id), `email`, `name`, `tid` (tenant id)
+ - Find-or-create `User` + `UserIdentity(provider=MICROSOFT, subject=oid, tenant_id=tid)`
+ - Call `OnboardingHook.onFirstSignIn(...)` for new users
+2. **W1-2** Update `LoginProvider` enum — add `MICROSOFT` (if not already present from v0.1's
+ `email` toggle). The enum is already provider-agnostic.
+3. **W1-3** Add `Microsoft` provider config to `PlateAuthProperties.Providers`:
+ ```java
+ @Data public static class Microsoft {
+ private boolean enabled = false;
+ private String tenantId = "common"; // "common", "organizations", or a specific GUID
+ }
+ ```
+4. **W1-4** The exchange envelope already has a `provider` field. MS Entra payloads use
+ `provider="microsoft"`. No envelope schema change needed — the existing `ExchangeEnvelope` is
+ provider-agnostic.
+5. **W1-5** Register the provider conditionally:
+ ```java
+ @Bean
+ @ConditionalOnProperty(prefix = "plate.auth.providers.microsoft", name = "enabled", havingValue = "true")
+ public MicrosoftEntraService microsoftEntraService(...) { ... }
+ ```
+ Fail-fast if enabled without `spring.security.oauth2.client.registration.microsoft.*` configured.
+
+**Done when:** A POST to `/api/auth/exchange` with `provider=microsoft` and a valid MS Entra
+`id_token` returns `{access_token, refresh_token, user, memberships}`. Existing Google flow is
+unaffected (T-IT10).
+
+### 3.2 Frontend
+
+**Steps:**
+
+1. **W1-6** Implement `packages/auth/src/config/providers/microsoft.ts`:
+ ```ts
+ export function microsoftProvider(opts: {
+ clientId: string;
+ clientSecret: string;
+ tenantId?: string; // default "common"
+ }): NextAuthConfig.Provider;
+ ```
+ Uses NextAuth v5's `MicrosoftEntraID` provider.
+2. **W1-7** Wire into `createAuthConfig(opts)` — add `microsoft?: MicrosoftOpts` to
+ `PlateAuthConfigOptions.providers`. If present, add the MS provider to the provider list.
+3. **W1-8** Update `ExchangeEnvelope` type — the `provider` union type becomes
+ `'google' | 'microsoft' | 'email' | 'password'` (already in v0.1's type, now *exercised*).
+4. **W1-9** Document env vars in [Architecture §4.2](Architecture.md):
+ ```
+ MICROSOFT_ENTRA_CLIENT_ID=...
+ MICROSOFT_ENTRA_CLIENT_SECRET=...
+ MICROSOFT_ENTRA_TENANT_ID=common
+ ```
+
+**Done when:** A Next.js app with `createAuthConfig({ providers: { microsoft: {...} } })` shows a
+"Sign in with Microsoft" button and completes the OAuth flow → exchange → JWT. Snapshot test
+verifies the provider is present in the config (T-FE06).
+
+### 3.3 InspectFlow regression test
+
+1. **W1-10** Verify against InspectFlow's seeded MS identities. The V5 migration
+ (`V5__add_microsoft_tenant_id_index.sql`) already indexes `user_identities.microsoft_tenant_id`.
+ A user with `provider=microsoft, subject=, tenant_id=` must sign in successfully and
+ get their existing memberships.
+
+**Done when:** InspectFlow MS user sign-in is green post-v0.2.0 migration. T-E2E07 validates this.
+
+---
+
+## 4. W2 — Email magic-link provider
+
+**Goal:** Add email magic-link sign-in as a second auth option. Users enter their email, receive a
+one-time link, click it, and are authenticated. No password required.
+
+**Branch:** `feature/sprint-1/w2-email-magic-link`
+
+### 4.1 Backend
+
+**Steps:**
+
+1. **W2-1** The exchange envelope already supports `provider="email"`. The backend
+ `ExchangeService.consume(...)` path is provider-agnostic — it verifies the HMAC envelope and
+ find-or-creates the user. No backend changes needed for the *exchange* itself.
+2. **W2-2** Add email-provider config to `PlateAuthProperties.Providers`:
+ ```java
+ @Data public static class EmailMagicLink {
+ private boolean enabled = false;
+ private String fromAddress; // e.g., "noreply@plate-software.de"
+ @Min(60) private int tokenTtlSeconds = 600; // 10 min magic-link TTL
+ }
+ ```
+3. **W2-3** The magic-link flow is handled by NextAuth's Email provider on the *frontend* side —
+ it sends the magic link email via SMTP. The backend's role is to receive the verified identity
+ via the exchange envelope (same as Google). No new backend endpoint.
+
+**Done when:** Backend accepts `provider="email"` envelopes. Config validation fails-fast if
+`email-magic-link.enabled=true` without SMTP config.
+
+### 4.2 Frontend
+
+**Steps:**
+
+1. **W2-4** Implement `packages/auth/src/config/providers/email.ts`:
+ ```ts
+ export function emailProvider(opts: {
+ server?: SMTPConfig; // or connection string
+ from: string;
+ }): NextAuthConfig.Provider;
+ ```
+ Uses NextAuth v5's `Email` provider (nodemailer-backed).
+2. **W2-5** Wire into `createAuthConfig(opts)` — add `email?: EmailOpts` to
+ `PlateAuthConfigOptions.providers`.
+3. **W2-6** Create a **default magic-link callback page** component that consumers can drop in:
+ `packages/auth/src/components/MagicLinkCallback.tsx`. This handles the NextAuth verify-callback
+ route. Consumers import it or build their own — documented in
+ [Integration-Guide.md](Integration-Guide.md).
+4. **W2-7** Security: verify `allowDangerousEmailAccountLinking=false` is enforced in the email
+ provider config. Snapshot test (T-FE07).
+
+**Done when:** A Next.js app with `createAuthConfig({ providers: { email: {...} } })` shows an email
+input, sends a magic link, and the callback completes the sign-in → exchange → JWT flow.
+
+### 4.3 Email enumeration guard
+
+1. **W2-8** The email-provider response must be **generic**: "If an account exists for this email,
+ a sign-in link has been sent." No "user not found" error. This prevents email enumeration.
+ Test: T-SEC12.
+
+**Done when:** Magic-link request for a non-existent email returns the same response as an existing
+email. No information leak.
+
+---
+
+## 5. W3 — LoginEventSink SPI + real mailers
+
+**Goal:** Two deliverables bundled here because they share the SPI/mailer architecture:
+
+1. **`LoginEventSink` SPI** — new extension point for consumers to tee login events to external
+ systems (Loki, SIEM, webhooks) instead of (or in addition to) DB rows.
+2. **Real `InvitationMailer` + `AccessRequestMailer`** — replace v0.1's no-op loggers with
+ `JavaMailSender`-backed implementations that actually send email.
+
+**Branch:** `feature/sprint-1/w3-spi-mailers`
+
+### 5.1 LoginEventSink SPI
+
+**Steps:**
+
+1. **W3-1** Create the SPI interface under `de.platesoft.auth.spi`:
+ ```java
+ public interface LoginEventSink {
+ /**
+ * Called after a login event is recorded. Implementations may ship to
+ * Loki, OpenSearch, Kafka, or any external sink. Must be non-blocking —
+ * called asynchronously by LoginEventService.
+ *
+ * @param event the recorded login event (never null)
+ */
+ void emit(LoginEvent event);
+ }
+ ```
+2. **W3-2** Create the default no-op implementation:
+ ```java
+ @Component
+ @ConditionalOnMissingBean(LoginEventSink.class)
+ public class NoOpLoginEventSink implements LoginEventSink {
+ @Override
+ public void emit(LoginEvent event) {
+ // no-op — default. Override to ship externally.
+ }
+ }
+ ```
+3. **W3-3** Wire `LoginEventSink` into `LoginEventService`:
+ - After `loginEventRepository.save(event)`, call `loginEventSink.emit(event)` **asynchronously**
+ (`@Async` or a dedicated executor).
+ - The sink must **not** block the login flow. A failing sink logs a WARN but never fails the
+ login.
+ - Wrap the `emit()` call in a try-catch that logs at WARN level on failure.
+4. **W3-4** Register the SPI in `PlateAuthAutoConfiguration` `@Import` list.
+5. **W3-5** Update [Architecture §3.4](Architecture.md) SPI table — add `LoginEventSink` as the 6th
+ SPI. Default = `NoOpLoginEventSink`.
+
+**Done when:** A consumer can `@Bean` a `LoginEventSink` and receive every login event. The default
+no-op sink has zero overhead. Login latency is unaffected (async dispatch). T-UT16 validates.
+
+### 5.2 Real InvitationMailer
+
+**Steps:**
+
+1. **W3-6** Create `de.platesoft.auth.spi.defaults.MailInvitationMailer`:
+ ```java
+ @Component
+ @ConditionalOnMissingBean(InvitationMailer.class)
+ @ConditionalOnProperty(prefix = "plate.auth.mail", name = "enabled", havingValue = "true")
+ public class MailInvitationMailer implements InvitationMailer {
+ private final JavaMailSender mailSender;
+ private final PlateAuthProperties properties;
+
+ @Override
+ public void sendInvitation(Invitation invitation, String acceptUrl) {
+ MimeMessage msg = mailSender.createMimeMessage();
+ // build HTML + plain-text multipart from template
+ // subject: "You're invited to join {orgDisplayName}"
+ // body: link to acceptUrl, expiration info
+ mailSender.send(msg);
+ }
+ }
+ ```
+2. **W3-7** The **default** `InvitationMailer` when `plate.auth.mail.enabled=false` (or unset)
+ remains the existing `LoggingInvitationMailer`. The new `MailInvitationMailer` activates only
+ when mail is explicitly enabled. This preserves v0.1's "boots green with zero config" guarantee.
+3. **W3-8** Create email templates:
+ - `src/main/resources/templates/invitation.html` — HTML body with accept-url placeholder
+ - `src/main/resources/templates/invitation.txt` — plain-text fallback
+ - Use Spring's `MimeMessageHelper` for multipart.
+4. **W3-9** Fail-loudly: if `plate.auth.mail.enabled=true` but `spring.mail.host` is unset, fail
+ fast at startup with a clear error: `"plate.auth.mail.enabled=true but spring.mail.host is not configured"`.
+
+**Done when:** With `plate.auth.mail.enabled=true` + SMTP config, inviting a user sends a real email
+with the accept link. Without mail config, the logging fallback activates. T-UT17 + T-IT11 validate.
+
+### 5.3 AccessRequestMailer admin template
+
+**Steps:**
+
+1. **W3-10** Create `de.platesoft.auth.spi.defaults.MailAccessRequestMailer` — same pattern as
+ `MailInvitationMailer`. Activates with `plate.auth.mail.enabled=true`.
+2. **W3-11** Two notification methods:
+ - `notifyAdmins(request)` — sends to configured admin email list
+ - `notifyRequester(request)` — sends approval/denial notification to the requester
+3. **W3-12** Templates:
+ - `templates/access-request-admin.html` — "New access request from {email}"
+ - `templates/access-request-decision.html` — "Your access request was {approved/denied}"
+4. **W3-13** Admin email list from config: `plate.auth.mail.admin-recipients` (comma-separated).
+
+**Done when:** Access request submission → admins notified. Approval/denial → requester notified.
+T-UT18 validates.
+
+### 5.4 Mail config additions
+
+Add to `PlateAuthProperties`:
+
+```java
+@Data public static class Mail {
+ private boolean enabled = false;
+ private String fromAddress = "noreply@plate-software.de";
+ private List adminRecipients = new ArrayList<>();
+}
+```
+
+```yaml
+plate:
+ auth:
+ mail:
+ enabled: false # opt-in; default is logging fallback
+ from-address: noreply@plate-software.de
+ admin-recipients:
+ - admin@plate-software.de
+```
+
+---
+
+## 6. W4 — RFC 7807 Problem Details + configurable expiration
+
+**Goal:** Two small must-haves bundled:
+
+1. **RFC 7807 Problem Details** — structured error responses (`application/problem+json`) on all
+ `/api/auth/*` endpoints so consumers can build robust error UX.
+2. **Configurable invitation expiration** — `plate.auth.invitation.expiration-days` (currently
+ hardcoded 7d).
+
+**Branch:** `feature/sprint-1/w4-problem-details-config`
+
+### 6.1 RFC 7807 Problem Details
+
+**Steps:**
+
+1. **W4-1** Create `config/PlateAuthProblemDetailHandler` — extends Spring Boot 4's
+ `ProblemDetailExceptionHandler` (Spring 6 / Boot 4 has built-in RFC 7807 support via
+ `@ControllerAdvice` + `ProblemDetail`). Wire it as a `@ControllerAdvice` for the
+ `de.platesoft.auth.controller` package.
+2. **W4-2** Define custom problem types for plate-auth-specific errors:
+ ```java
+ public enum PlateAuthProblems {
+ EXCHANGE_HMAC_INVALID("https://plate-software.de/errors/exchange-hmac-invalid", 401),
+ EXCHANGE_EXPIRED("https://plate-software.de/errors/exchange-expired", 401),
+ EXCHANGE_REPLAY("https://plate-software.de/errors/exchange-replay", 409),
+ ORG_VALIDATION_FAILED("https://plate-software.de/errors/org-validation-failed", 400),
+ INVITATION_EXPIRED("https://plate-software.de/errors/invitation-expired", 410),
+ INVITATION_REVOKED("https://plate-software.de/errors/invitation-revoked", 410);
+ // ... each maps to a `type` URI + status code
+ }
+ ```
+3. **W4-3** Ensure all v0.1 custom exceptions map to `ProblemDetail`:
+ - `ExchangeHmacInvalidException` → 401 + `EXCHANGE_HMAC_INVALID` type
+ - `ExchangeReplayException` → 409 + `EXCHANGE_REPLAY` type
+ - `ExchangeExpiredException` → 401 + `EXCHANGE_EXPIRED` type
+ - `OrgValidationException` → 400 + `ORG_VALIDATION_FAILED` type
+ - Generic `BadCredentials` → 401 + `https://plate-software.de/errors/bad-credentials`
+4. **W4-4** Security: Problem Details responses must **not** leak stack traces, SQL fragments, or
+ internal class names. The `detail` field is consumer-safe; the `instance` field is the request
+ path. Test T-SEC13.
+5. **W4-5** Set `Content-Type: application/problem+json` on all error responses from plate-auth
+ endpoints. v0.1's default Spring Boot error body is replaced.
+
+**Done when:** A failed login returns `application/problem+json` with `type`, `title`, `status`,
+`detail`, `instance`. A replayed exchange envelope returns 409 with the `EXCHANGE_REPLAY` type.
+T-UT19 + T-IT12 validate.
+
+### 6.2 Configurable invitation expiration
+
+**Steps:**
+
+1. **W4-6** Add to `PlateAuthProperties`:
+ ```java
+ @Data public static class Invitation {
+ @Min(1) @Max(90)
+ private int expirationDays = 7;
+ }
+ ```
+2. **W4-7** Update `InvitationService.create(...)` — replace hardcoded `Duration.ofDays(7)` with
+ `Duration.ofDays(properties.getInvitation().getExpirationDays())`.
+3. **W4-8** Bean-validate: `@Min(1) @Max(90)` prevents 0/negative/absurd values. Fails fast at
+ startup.
+4. **W4-9** Document the config in [Architecture §3.3](Architecture.md):
+ ```yaml
+ plate:
+ auth:
+ invitation:
+ expiration-days: 7 # default; range 1–90
+ ```
+
+**Done when:** Setting `plate.auth.invitation.expiration-days=3` produces invitations that expire
+in 3 days. Setting it to 0 or 100 fails startup validation. T-UT20 validates.
+
+---
+
+## 7. W5 — TypeScript improvements + Edge-runtime safety
+
+**Goal:** Two frontend DX improvements:
+
+1. **TypeScript type exports + Zod schemas** — export proper types for `Membership`, `Invitation`,
+ `AccessRequest` and add runtime-validation Zod schemas for the exchange envelope + DTOs.
+2. **Edge-runtime safe `useAccessToken()`** — replace the App-Router-only `getSession()` call with
+ an Edge-compatible approach.
+
+**Branch:** `feature/sprint-1/w5-ts-edge`
+
+### 7.1 TypeScript type exports + Zod schemas
+
+**Steps:**
+
+1. **W5-1** Create `packages/auth/src/types/index.ts` — export domain types mirroring backend entities:
+ ```ts
+ export interface Membership {
+ id: string;
+ userId: string;
+ orgType: string;
+ orgId: string;
+ role: MembershipRole;
+ status: MembershipStatus;
+ }
+ export type MembershipRole = 'OWNER' | 'ADMIN' | 'MEMBER' | 'VIEWER';
+ export type MembershipStatus = 'ACTIVE' | 'SUSPENDED' | 'REVOKED';
+
+ export interface Invitation {
+ id: string;
+ email: string;
+ orgType: string;
+ orgId: string;
+ role: MembershipRole;
+ status: InvitationStatus;
+ expiresAt: string; // ISO-8601
+ }
+ export type InvitationStatus = 'PENDING' | 'ACCEPTED' | 'REVOKED' | 'EXPIRED';
+
+ export interface AccessRequest {
+ id: string;
+ requesterId: string;
+ orgType: string;
+ orgId: string;
+ requestedRole: MembershipRole;
+ status: AccessRequestStatus;
+ }
+ export type AccessRequestStatus = 'PENDING' | 'APPROVED' | 'DENIED' | 'EXPIRED';
+
+ export interface TokenResponse {
+ accessToken: string;
+ refreshToken: string;
+ user: PlateAuthUser;
+ memberships: Membership[];
+ }
+ export interface PlateAuthUser {
+ id: string;
+ email: string;
+ role: 'USER' | 'ADMIN';
+ firstName?: string;
+ lastName?: string;
+ }
+ ```
+2. **W5-2** Re-export from `packages/auth/src/index.ts` so consumers can
+ `import { Membership, Invitation, AccessRequest } from '@platesoft/auth'`.
+3. **W5-3** Update `useMemberships()` return type from implicit `any` to `Membership[]`.
+ Update `useAccessToken()` return type to explicit `string | null`.
+4. **W5-4** Add Zod schemas in `packages/auth/src/schemas/index.ts`:
+ ```ts
+ import { z } from 'zod';
+
+ export const ExchangeEnvelopeSchema = z.object({
+ wireVersion: z.literal(1).or(z.literal(2)),
+ provider: z.enum(['google', 'microsoft', 'email', 'password']),
+ providerSubject: z.string(),
+ email: z.string().email(),
+ name: z.string().optional(),
+ inviteToken: z.string().optional(),
+ nonce: z.string().uuid(),
+ iat: z.number().int().positive(),
+ });
+
+ export const TokenResponseSchema = z.object({
+ accessToken: z.string(),
+ refreshToken: z.string(),
+ user: z.object({
+ id: z.string().uuid(),
+ email: z.string().email(),
+ role: z.enum(['USER', 'ADMIN']),
+ firstName: z.string().optional(),
+ lastName: z.string().optional(),
+ }),
+ memberships: z.array(z.object({
+ orgType: z.string(),
+ orgId: z.string().uuid(),
+ role: z.enum(['OWNER', 'ADMIN', 'MEMBER', 'VIEWER']),
+ status: z.enum(['ACTIVE', 'SUSPENDED', 'REVOKED']),
+ })),
+ });
+ ```
+5. **W5-5** Use `TokenResponseSchema.parse()` in the exchange client to validate backend response
+ at runtime — catches contract drift early.
+6. **W5-6** Add `zod` as a dependency (not peer dep) in `packages/auth/package.json`.
+
+**Done when:** `import { Membership, Invitation, ExchangeEnvelopeSchema } from '@platesoft/auth'`
+resolves with full type safety. The exchange client validates the backend response via Zod.
+T-FE08 + T-FE09 validate.
+
+### 7.2 Edge-runtime safe useAccessToken()
+
+**Steps:**
+
+1. **W5-7** The current `useAccessToken()` calls `getSession()` which uses Node-only APIs. This
+ throws in the Edge runtime. Fix: detect runtime and use a cookie-based fallback:
+ ```ts
+ export function useAccessToken(): string | null {
+ // Edge-runtime safe: read from a cookie set by the proxy handler
+ const cookieValue = getCookie('plate-auth-token');
+ return cookieValue ?? null;
+ }
+ ```
+ Or, if called in a client component (browser), use `useSession()` as before.
+2. **W5-8** Document the two modes:
+ - **Client component (browser):** `useSession()` — token in NextAuth JWT (encrypted cookie)
+ - **Edge middleware:** read `plate-auth-token` cookie (set by proxy handler)
+3. **W5-9** Add an Edge-runtime test (`@edge-runtime/vm` or `vitest` with Edge env) that imports
+ `useAccessToken()` and verifies it does not throw (T-FE10).
+
+**Done when:** `useAccessToken()` works in both browser and Edge runtime without throwing. No
+Node-only API imported in the Edge entry point. T-FE10 validates.
+
+---
+
+## 8. W6 — Wire-version assessment + integration tests + publish v0.2.0
+
+**Goal:** Assess whether the wire-version needs bumping, write the v0.2 integration tests, and
+publish `v0.2.0` to the Gitea Package Registry.
+
+**Branch:** `feature/sprint-1/w6-publish`
+
+### 8.1 Wire-version assessment
+
+**Steps:**
+
+1. **W6-1** Review whether any v0.2 change alters the exchange envelope contract:
+ - MS Entra ID: `provider="microsoft"` — already in the v0.1 `provider` union. **No change.**
+ - Email magic-link: `provider="email"` — already in the v0.1 `provider` union. **No change.**
+ - LoginEventSink: backend-only, no envelope impact. **No change.**
+ - RFC 7807: response-only, no request/envelope change. **No change.**
+ - TS types/Zod: frontend-only, no wire impact. **No change.**
+ - Config expiration: backend-only. **No change.**
+2. **W6-2** **Decision point:** If all changes fit the existing `ExchangeEnvelope` shape, then
+ `WIRE_VERSION` stays at **1**. v0.2.0 is a pure additive release.
+ - **If** a provider-specific field is added to the envelope, then bump `WIRE_VERSION` to **2**.
+ - **Prediction:** No bump needed. The envelope is provider-agnostic by design.
+
+**Done when:** Wire-version decision is documented in CHANGELOG.
+
+### 8.2 Integration tests
+
+**Steps:**
+
+1. **W6-3** Implement **T-IT10..13** (new integration tests for v0.2):
+ - T-IT10: MS Entra exchange flow — mock MS JWKS, verify token → user → JWT
+ - T-IT11: Invitation mailer IT — `plate.auth.mail.enabled=true` + GreenMail → email sent
+ - T-IT12: RFC 7807 error responses — failed login returns `application/problem+json`
+ - T-IT13: Multi-provider IT — Google + MS + email all configured, each completes exchange
+2. **W6-4** Run full regression: `mvn -pl plate-auth-starter verify` + `mvn -pl it verify` + all
+ v0.1 tests still pass.
+
+**Done when:** All v0.1 + v0.2 tests green against Testcontainers Postgres.
+
+### 8.3 Publish v0.2.0
+
+**Steps:**
+
+1. **W6-5** Update `CHANGELOG.md` with v0.2.0 release notes (new features + migration notes).
+2. **W6-6** Bump version: `pom.xml` revision → `0.2.0`, `packages/auth/package.json` → `0.2.0`.
+3. **W6-7** Cut `v0.2.0` git tag. Gitea Actions publishes both artifacts.
+4. **W6-8** Verify: `mvn dependency:get de.platesoft:plate-auth-starter:0.2.0` + `npm view
+ @platesoft/auth@0.2.0` from a fresh machine.
+
+**Done when:** `v0.2.0` tag publishes both artifacts to the Gitea Package Registry.
+
+---
+
+## 9. Security review checklist additions
+
+Incremental over v0.1's security checklist ([Sprint-0-Plan §9](Sprint-0-Plan.md)). Items marked
+`[v0.2-new]` are new for this sprint.
+
+### 9.1 MS Entra ID provider [v0.2-new]
+
+- [ ] MS Entra `id_token` signature verified against MS JWKS endpoint (not just decoded).
+- [ ] Tenant ID (`tid`) extracted and stored in `user_identities.tenant_id`.
+- [ ] Provider is `@ConditionalOnProperty`. Default disabled. Fail-fast without client-id/secret.
+- [ ] `tenantId` config exposed: `common`, `organizations`, or a specific GUID.
+
+### 9.2 Email magic-link [v0.2-new]
+
+- [ ] `allowDangerousEmailAccountLinking = false` — verified by snapshot test (T-FE07).
+- [ ] Magic-link request response is **generic** — no email enumeration (T-SEC12).
+- [ ] Magic-link token TTL configurable (default 600s/10min). Token is single-use.
+- [ ] Magic-link emails do not contain PII beyond the targeted email address.
+
+### 9.3 LoginEventSink SPI [v0.2-new]
+
+- [ ] SPI invocation is **asynchronous** — does not block login flow (T-UT16).
+- [ ] Failing sink logs WARN but never propagates exception to the login path.
+- [ ] `LoginEvent` passed to sink does not contain the password or full JWT.
+
+### 9.4 Real mailers [v0.2-new]
+
+- [ ] `MailInvitationMailer` fails loudly on SMTP failure (throws, does not swallow).
+- [ ] Invitation email does not leak the org's internal ID — uses `OrgDisplayNameResolver`.
+- [ ] Accept URL uses HTTPS (validated by config).
+- [ ] `plate.auth.mail.enabled=false` (default) → logging fallback. No SMTP connection attempted.
+
+### 9.5 RFC 7807 Problem Details [v0.2-new]
+
+- [ ] Error responses include no stack traces, no SQL fragments, no internal class names (T-SEC13).
+- [ ] `detail` field is consumer-safe (human-readable, no secrets).
+- [ ] `type` URIs are stable and documented.
+- [ ] Problem Details responses set `Content-Type: application/problem+json`.
+- [ ] Login-failure Problem Detail says "invalid credentials" — no user-exists leak.
+
+### 9.6 Wire-version [v0.2-new] (only if bumped)
+
+- [ ] If `WIRE_VERSION` bumped to 2: backend rejects version-1 envelopes with a clear error.
+- [ ] If bumped: both consumers upgrade lockstep per versioning policy.
+- [ ] If not bumped: no action needed — v0.2 is additive.
+
+---
+
+## 10. Rollout plan
+
+### 10.1 Consumer upgrade path
+
+v0.2.0 is **additive** (assuming no wire-version bump). Upgrade for each consumer:
+
+**InspectFlow:**
+1. Change `pom.xml`: `plate-auth-starter` version `0.1.0` → `0.2.0`
+2. Change `package.json`: `@platesoft/auth` version `0.1.0` → `0.2.0`
+3. Enable MS Entra: `plate.auth.providers.microsoft.enabled=true` + MS client-id/secret env vars
+4. Enable mail: `plate.auth.mail.enabled=true` + SMTP config
+5. Optionally: configure `plate.auth.invitation.expiration-days`
+6. Optionally: add a `LoginEventSink` bean to tee events to Loki/SIEM
+7. Frontend: update types — `useMemberships()` now returns `Membership[]`
+8. Run full E2E suite — must pass
+
+**Sparkboard:**
+1. Same version bumps
+2. Enable email magic-link if needed: `plate.auth.providers.email-magic-link.enabled=true` + SMTP
+3. Optionally: import Zod schemas for runtime validation
+4. Optionally: use the default `MagicLinkCallback` component
+
+### 10.2 Rollback strategy
+
+v0.2.0 rollback is **safe** — no Flyway migration is added (prediction). If a consumer needs to
+revert to v0.1.0:
+1. Revert dependency versions in `pom.xml` + `package.json`
+2. Redeploy
+3. Database is unaffected (no schema change)
+
+---
+
+## 11. Acceptance criteria
+
+| # | Criterion | How verified |
+|---|---|---|
+| B1 | MS Entra ID provider works end-to-end | T-IT10 + T-E2E07 |
+| B2 | Email magic-link provider works end-to-end | T-FE07 + manual test |
+| B3 | `LoginEventSink` SPI fires on login events | T-UT16 |
+| B4 | Real `InvitationMailer` sends email | T-UT17 + T-IT11 |
+| B5 | RFC 7807 Problem Details on `/api/auth/*` | T-UT19 + T-IT12 |
+| B6 | Configurable invitation expiration works | T-UT20 |
+| B7 | TS types + Zod schemas exported | T-FE08 + T-FE09 |
+| B8 | Edge-runtime safe `useAccessToken()` | T-FE10 |
+| B9 | Both artifacts published at 0.2.0 | `mvn dependency:get` + `npm view` |
+| B10 | All v0.1 tests still pass (no regression) | Full `mvn verify` + `pnpm test` |
+
+---
+
+## 12. Items deferred to v0.3+
+
+- Multi-replica nonce store (Redis/Postgres UPSERT)
+- Refresh-token rotation table + family-tracking (T-SEC10 from v0.1)
+- JWT secret rotation via JWK Set + `kid` header
+- Session sliding vs absolute expiration toggle
+- `requireRoles(['ADMIN'])` middleware helper
+- WebAuthn / passkey provider (if consumer demand emerges)
+- Per-app branding on exchange endpoint
+- Account lockout after N failed logins
+- 2FA / TOTP
+
+---
+
+## 13. Cross-references
+
+- Assessment: [`Sprint-1-Assessment.md`](Sprint-1-Assessment.md)
+- Test plan: [`Sprint-1-Testplan.md`](Sprint-1-Testplan.md)
+- v0.1 plan: [`Sprint-0-Plan.md`](Sprint-0-Plan.md)
+- Architecture: [`Architecture.md`](Architecture.md)
+- Roadmap: [`Roadmap.md`](Roadmap.md) (v0.2 section)
+- Open questions: [`Open-Questions.md`](Open-Questions.md)
+
+---
+
+*End of plan v1. Ready for Plan Reviewer.*
diff --git a/Sprint-1-Testplan.md b/Sprint-1-Testplan.md
new file mode 100644
index 0000000..8101665
--- /dev/null
+++ b/Sprint-1-Testplan.md
@@ -0,0 +1,382 @@
+# Sprint 1 — Test Plan
+
+**Status:** Draft v1
+**Date:** 2026-06-24
+**Owner:** Patrick
+**Scope:** Validates Sprint 1 deliverable: `de.platesoft:plate-auth-starter:0.2.0` + `@platesoft/auth@0.2.0`
+**Basis:** [Sprint-1-Plan.md](Sprint-1-Plan.md)
+**Continuation:** Test IDs continue from v0.1 (Sprint-0-Testplan: T-UT15, T-IT09, T-SEC10, T-FE05, T-E2E06, T-PERF03)
+
+---
+
+## 1. Reading guide
+
+This test plan enumerates every **new** test case for v0.2. It does **not** re-list v0.1 tests —
+those must still pass (regression gate). All v0.1 test IDs ([T-UT01..15](Sprint-0-Testplan.md),
+T-IT01..09, T-SEC01..09, T-FE01..05, T-E2E01..06, T-PERF01..03) remain in effect and must be green.
+
+Each test case has:
+
+- **ID** (continuing the v0.1 numbering: T-UTxx, T-ITxx, T-SECxx, T-FExx, T-E2Exx)
+- **Type** (Unit / Integration / Frontend-Unit / E2E / Security)
+- **Class / spec file**
+- **Scenarios** (Given / When / Then)
+- **Expected result**
+- **Acceptance criterion mapped** (B1..B10 from [Sprint-1-Plan.md §11](Sprint-1-Plan.md))
+
+Status legend: ⬜ Open · 🟡 In progress · ✅ Passed · ❌ Failed · ⏭️ Skipped
+
+---
+
+## 2. Test overview (master table)
+
+| ID | Type | Class / Spec | Maps to | Status |
+|----|------|--------------|---------|--------|
+| T-UT16 | Unit | `LoginEventSinkTest` (async dispatch) | B3 | ⬜ |
+| T-UT17 | Unit | `MailInvitationMailerTest` | B4 | ⬜ |
+| T-UT18 | Unit | `MailAccessRequestMailerTest` | B4 | ⬜ |
+| T-UT19 | Unit | `PlateAuthProblemDetailTest` | B5 | ⬜ |
+| T-UT20 | Unit | `InvitationExpirationConfigTest` | B6 | ⬜ |
+| T-IT10 | Integration | `MicrosoftEntraExchangeIT` | B1 | ⬜ |
+| T-IT11 | Integration | `InvitationMailerIT` (GreenMail) | B4 | ⬜ |
+| T-IT12 | Integration | `ProblemDetailResponseIT` | B5 | ⬜ |
+| T-IT13 | Integration | `MultiProviderExchangeIT` | B1, B2 | ⬜ |
+| T-SEC11 | Security | Email enumeration guard (magic-link) | B2 | ⬜ |
+| T-SEC12 | Security | Magic-link token replay rejected | B2 | ⬜ |
+| T-SEC13 | Security | Problem Details no information leak | B5 | ⬜ |
+| T-FE06 | Frontend-Unit | `microsoft-provider.test.ts` | B1 | ⬜ |
+| T-FE07 | Frontend-Unit | `email-provider.test.ts` | B2 | ⬜ |
+| T-FE08 | Frontend-Unit | `type-exports.test.ts` | B7 | ⬜ |
+| T-FE09 | Frontend-Unit | `zod-schemas.test.ts` | B7 | ⬜ |
+| T-FE10 | Frontend-Unit | `edge-runtime.test.ts` | B8 | ⬜ |
+| T-E2E07 | E2E | InspectFlow MS Entra sign-in (regression) | B1, B10 | ⬜ |
+
+**v0.2 new tests:** 18 — 5 Unit, 4 Integration, 3 Security, 5 Frontend-Unit, 1 E2E.
+
+**Combined v0.1 + v0.2 total:** 61 test cases (43 from v0.1 + 18 from v0.2).
+
+---
+
+## 3. Unit tests (backend)
+
+### T-UT16 — LoginEventSink fires asynchronously
+
+- **Class:** `de.platesoft.auth.spi.LoginEventSinkTest`
+- **Given:** A `LoginEventSink` that sleeps 500ms in `emit(...)` (simulating slow external sink).
+ `LoginEventService` configured with the sink.
+- **When:** A successful login triggers `LoginEventService.recordSuccess(...)`.
+- **Then:** The `recordSuccess(...)` method returns **immediately** (< 50ms wall clock) — the sink
+ dispatch is async. After 600ms, the sink's `emit(...)` was called exactly once with the correct
+ `LoginEvent`. A failing sink (throws `RuntimeException`) logs a WARN but does not propagate to
+ the login path.
+
+### T-UT17 — MailInvitationMailer sends invitation email
+
+- **Class:** `de.platesoft.auth.spi.defaults.MailInvitationMailerTest`
+- **Given:** `plate.auth.mail.enabled=true`, `JavaMailSender` mocked. An `Invitation` with
+ `email=newuser@test.de`, `orgType=COMPANY`, `orgId=`, `role=MEMBER`. Accept URL =
+ `https://app.example.com/invite/accept?token=abc123`.
+- **When:** `sendInvitation(invitation, acceptUrl)` called.
+- **Then:** `JavaMailSender.send(...)` invoked exactly once. The `MimeMessage` has:
+ - `To: newuser@test.de`
+ - `From: noreply@plate-software.de` (from config)
+ - Subject contains the org display name (via `OrgDisplayNameResolver`)
+ - Body (text + HTML multipart) contains the accept URL
+- **Also:** If `JavaMailSender.send(...)` throws `MailSendException`, `sendInvitation(...)`
+ **re-throws** (does not swallow). Verified by a second scenario.
+
+### T-UT18 — MailAccessRequestMailer sends admin + requester notifications
+
+- **Class:** `de.platesoft.auth.spi.defaults.MailAccessRequestMailerTest`
+- **Given:** `plate.auth.mail.enabled=true`, `admin-recipients=[admin1@test.de, admin2@test.de]`.
+ An `AccessRequest` with `status=PENDING`, `requester.email=applicant@test.de`.
+- **When:** `notifyAdmins(request)` called.
+- **Then:** `JavaMailSender.send(...)` invoked once with `To: admin1@test.de, admin2@test.de`.
+ Subject: "New access request from applicant@test.de".
+- **When:** `notifyRequester(request)` called with `status=APPROVED`.
+- **Then:** Email sent to `applicant@test.de`. Subject contains "approved".
+
+### T-UT19 — Problem Details format for auth errors
+
+- **Class:** `de.platesoft.auth.config.PlateAuthProblemDetailTest`
+- **Given:** Spring MVC test with `PlateAuthProblemDetailHandler` active.
+- **Scenarios (parameterized):**
+ - `ExchangeHmacInvalidException` → `ProblemDetail` with `status=401`,
+ `type=https://plate-software.de/errors/exchange-hmac-invalid`, `title="Exchange HMAC invalid"`
+ - `ExchangeReplayException` → `status=409`, `type=.../exchange-replay`
+ - `ExchangeExpiredException` → `status=401`, `type=.../exchange-expired`
+ - `OrgValidationException` → `status=400`, `type=.../org-validation-failed`
+ - `BadCredentialsException` → `status=401`, `type=.../bad-credentials`,
+ `detail="Invalid credentials"` (no "user not found" leak)
+- **Then:** All responses have `Content-Type: application/problem+json`. Each `ProblemDetail` has
+ `type`, `title`, `status`, `detail`, `instance` fields populated. No stack trace, no SQL
+ fragment, no internal class name in any field.
+
+### T-UT20 — Configurable invitation expiration
+
+- **Class:** `de.platesoft.auth.config.InvitationExpirationConfigTest`
+- **Scenarios (parameterized):**
+ - `plate.auth.invitation.expiration-days=3` → `InvitationService.create(...)` produces
+ `expires_at ≈ now + 3 days` (±2s)
+ - `plate.auth.invitation.expiration-days=14` → `expires_at ≈ now + 14 days`
+ - `plate.auth.invitation.expiration-days=0` → fails startup (`@Min(1)` violated)
+ - `plate.auth.invitation.expiration-days=100` → fails startup (`@Max(90)` violated)
+ - No config → default 7 days (v0.1 behavior preserved)
+- **Then:** Each scenario produces the expected result. Startup failures throw
+ `BindValidationException` with the property path `plate.auth.invitation.expiration-days`.
+
+---
+
+## 4. Integration tests (backend, Testcontainers)
+
+Same strategy as v0.1: `@SpringBootTest` + `PostgreSQLContainer` (Testcontainers Postgres 16).
+
+### T-IT10 — MS Entra ID exchange flow
+
+- **Class:** `de.platesoft.auth.exchange.MicrosoftEntraExchangeIT`
+- **Given:** Starter booted with `plate.auth.providers.microsoft.enabled=true`. MS JWKS endpoint
+ mocked (wiremock or mock-server). A pre-seeded `UserIdentity(provider=MICROSOFT,
+ subject=, tenant_id=)` exists.
+- **When:** POST `/api/auth/exchange` with envelope `{provider="microsoft", providerSubject=,
+ email=user@test.de, tenantId=, nonce=, iat=}` signed with the exchange secret.
+- **Then:** 200 response with `accessToken` (valid JWT for the seeded user) + `refreshToken` +
+ `user` + `memberships`. The existing Google exchange flow (T-IT03) is unaffected — verified by
+ running both in the same test class.
+
+### T-IT11 — Invitation mailer sends real email (GreenMail)
+
+- **Class:** `de.platesoft.auth.invitation.InvitationMailerIT`
+- **Given:** Starter booted with `plate.auth.mail.enabled=true` + GreenMail SMTP test server
+ (`greenmail-smtp` Testcontainer or embedded GreenMail). Admin invites `newuser@test.de`.
+- **When:** `InvitationService.create(adminId, "newuser@test.de", orgType, orgId, MEMBER)`.
+- **Then:** GreenMail inbox for `newuser@test.de` contains exactly one email. Subject contains the
+ org display name. Body contains the accept URL. HTML + plain-text multipart present.
+- **Also:** With `plate.auth.mail.enabled=false` (default), no email is sent —
+ `LoggingInvitationMailer` logs instead. Verified by a second scenario.
+
+### T-IT12 — RFC 7807 Problem Details on HTTP layer
+
+- **Class:** `de.platesoft.auth.config.ProblemDetailResponseIT`
+- **Given:** Full Spring Boot test context with Testcontainers Postgres.
+- **When:**
+ - POST `/api/auth/exchange` with tampered HMAC signature
+ - POST `/api/auth/login` with wrong password
+ - POST `/api/auth/exchange` with replayed nonce
+- **Then:** Each response has `Content-Type: application/problem+json`. JSON body contains `type`,
+ `title`, `status`, `detail`, `instance`. `status` matches expected HTTP code (401, 401, 409
+ respectively). No stack trace or SQL in the body. No `exception` field leaked.
+
+### T-IT13 — Multi-provider exchange (Google + MS + email)
+
+- **Class:** `de.platesoft.auth.exchange.MultiProviderExchangeIT`
+- **Given:** Starter booted with all three providers enabled. MS JWKS mocked. Email provider
+ configured.
+- **When:** Three separate exchange requests: `{provider="google"}`,
+ `{provider="microsoft"}`, `{provider="email"}`.
+- **Then:** Each returns 200 with valid JWT. Three distinct `UserIdentity` rows created with
+ different `provider` values. Each user's `LoginEvent` row has the correct `provider` field.
+
+---
+
+## 5. Security tests
+
+### T-SEC11 — Email enumeration guard (magic-link)
+
+- **Test:** Request magic link for `existing@test.de` (user exists) and `nonexistent@test.de`
+ (no user). Both via the email magic-link flow.
+- **Then:** **Both** responses are identical — same status code (200), same body
+ ("If an account exists for this email, a sign-in link has been sent"). No timing difference
+ > 50ms between the two. A magic link is sent only for the existing email (verified via mailer
+ mock). No information leak that distinguishes existing from non-existing users.
+
+### T-SEC12 — Magic-link token replay rejected
+
+- **Test:** Complete a magic-link sign-in (receive token, click, exchange). Then attempt to reuse
+ the same magic-link token for a second sign-in.
+- **Then:** Second use of the token is rejected — NextAuth Email provider tokens are single-use.
+ The exchange envelope nonce is also deduped by `ExchangeService` (v0.1 protection). No duplicate
+ `LoginEvent` row created.
+
+### T-SEC13 — Problem Details no information leak
+
+- **Test:** Trigger various error conditions (tampered HMAC, expired JWT, SQL-injection probe on
+ `/auth/login`, invalid invitation token). Capture the raw HTTP response body.
+- **Then:** Response body (`application/problem+json`) contains **none** of:
+ - Stack traces (`at de.platesoft...`)
+ - SQL fragments (`SELECT`, `WHERE`, table names)
+ - Internal class names (`org.hibernate...`, `com.zaxxer...`)
+ - Environment variables or secrets
+ - Full JWT tokens
+- **Method:** Regex scan of the response body against a denylist pattern. Fails if any pattern
+ matches.
+
+---
+
+## 6. Frontend unit tests (`@platesoft/auth`)
+
+Vitest + jsdom (same as v0.1). v0.2 tests live alongside v0.1 tests in the same test directories.
+
+### T-FE06 — Microsoft provider snapshot
+
+- **Spec:** `microsoft-provider.test.ts`
+- **Given:** `createAuthConfig({ providers: { microsoft: { clientId: 'x', clientSecret: 'y',
+ tenantId: 'common' } } })`.
+- **Then:** Returned NextAuth config `providers` array contains a Microsoft Entra ID provider
+ entry. The `signIn` callback builds an envelope with `provider="microsoft"`. The provider has
+ `tenantId: 'common'`.
+
+### T-FE07 — Email provider + email-linking guard
+
+- **Spec:** `email-provider.test.ts`
+- **Given:** `createAuthConfig({ providers: { email: { server: 'smtp://...', from:
+ 'noreply@test.de' } } })`.
+- **Then:** Returned config contains an Email provider entry.
+ `allowDangerousEmailAccountLinking` is `false` in the provider config — verified by reading the
+ provider object from the returned config. If a consumer tries to set it `true`, the factory
+ overrides it to `false` and logs a WARN.
+
+### T-FE08 — Type exports resolve correctly
+
+- **Spec:** `type-exports.test.ts`
+- **Then:** All of the following resolve without TypeScript errors:
+ ```ts
+ import { Membership, MembershipRole, MembershipStatus,
+ Invitation, InvitationStatus,
+ AccessRequest, AccessRequestStatus,
+ TokenResponse, PlateAuthUser } from '@platesoft/auth';
+ ```
+ `useMemberships()` return type is `Membership[]` (not `any`). `useAccessToken()` return type is
+ `string | null`. Verified by `tsc --noEmit` against a test file that uses these types.
+
+### T-FE09 — Zod schemas validate correctly
+
+- **Spec:** `zod-schemas.test.ts`
+- **Then:**
+ - `ExchangeEnvelopeSchema.parse(validEnvelope)` succeeds
+ - `ExchangeEnvelopeSchema.parse({ provider: 'invalid' })` throws (invalid enum)
+ - `ExchangeEnvelopeSchema.parse({ nonce: 'not-a-uuid' })` throws
+ - `TokenResponseSchema.parse(validTokenResponse)` succeeds
+ - `TokenResponseSchema.parse({ accessToken: 123 })` throws (wrong type)
+ - The exchange client (`exchangeWithBackend`) calls `TokenResponseSchema.parse()` on the
+ backend response — verified by mocking the fetch and checking that a malformed response throws.
+
+### T-FE10 — Edge-runtime safe useAccessToken()
+
+- **Spec:** `edge-runtime.test.ts`
+- **Given:** Test environment configured with `@edge-runtime/vm` (or vitest Edge environment).
+- **When:** `import { useAccessToken } from '@platesoft/auth/client'` in Edge runtime. Call
+ `useAccessToken()`.
+- **Then:** Does **not** throw. Returns `string | null`. No Node-only API (`getSession()`,
+ `cookies()`, `headers()`) is imported in the module graph — verified by bundle analysis (the Edge
+ entry point contains no `import ... from 'next/headers'` or similar).
+- **Also:** In browser/jsdom environment, `useAccessToken()` still works via `useSession()` (v0.1
+ behavior preserved).
+
+---
+
+## 7. End-to-end regression (InspectFlow)
+
+### T-E2E07 — InspectFlow MS Entra sign-in (post-v0.2 upgrade)
+
+- **Spec:** InspectFlow Playwright suite (new scenario added for v0.2)
+- **Given:** InspectFlow migrated to plate-auth v0.2.0 with MS Entra ID enabled.
+ `plate.auth.providers.microsoft.enabled=true`.
+- **When:** A user with an existing `provider=microsoft` identity (seeded from InspectFlow's
+ pre-migration data) signs in via Microsoft.
+- **Then:** OAuth callback → exchange → JWT issued. User lands on dashboard with their existing
+ memberships. No data loss — the `UserIdentity` row from v0.1 (same `subject` + `tenant_id`) is
+ matched, not duplicated.
+- **Pass criterion (B1 + B10):** This test must be green. If it fails, it means the MS Entra
+ provider doesn't match InspectFlow's existing identities — this is a **release blocker**.
+
+**All v0.1 E2E tests (T-E2E01..06) must also remain green** — v0.2 must not regress InspectFlow.
+
+---
+
+## 8. Acceptance criteria → tests matrix
+
+Mapping back to [Sprint-1-Plan.md §11](Sprint-1-Plan.md):
+
+| B# | Acceptance criterion | Test IDs |
+|----|---------------------|----------|
+| B1 | MS Entra ID provider works | T-IT10, T-IT13, T-FE06, T-E2E07 |
+| B2 | Email magic-link works | T-IT13, T-FE07, T-SEC11, T-SEC12 |
+| B3 | LoginEventSink SPI fires async | T-UT16 |
+| B4 | Real InvitationMailer sends email | T-UT17, T-UT18, T-IT11 |
+| B5 | RFC 7807 Problem Details | T-UT19, T-IT12, T-SEC13 |
+| B6 | Configurable invitation expiration | T-UT20 |
+| B7 | TS types + Zod schemas exported | T-FE08, T-FE09 |
+| B8 | Edge-runtime safe hooks | T-FE10 |
+| B9 | Both artifacts published at 0.2.0 | `mvn dependency:get` + `npm view` |
+| B10 | All v0.1 tests still pass | T-UT01..15, T-IT01..09, T-SEC01..09, T-FE01..05, T-E2E01..07 |
+
+Every v0.2 acceptance criterion has at least one mapped test. **B10** is the regression gate —
+all 43 v0.1 tests + the 18 new v0.2 tests must pass.
+
+---
+
+## 9. Test data + fixtures (incremental)
+
+Building on v0.1's fixtures ([Sprint-0-Testplan §10](Sprint-0-Testplan.md)):
+
+| Fixture | Purpose |
+|---------|---------|
+| MS JWKS mock (wiremock) | T-IT10, T-IT13 — mock the Microsoft Entra JWKS endpoint with a test key pair |
+| GreenMail SMTP container | T-IT11 — in-memory mail server for real mailer tests |
+| `RecordingLoginEventSink` | T-UT16 — test sink that captures `emit()` calls and can simulate delay/failure |
+| Zod test fixtures | T-FE09 — valid + invalid envelope/token-response JSON for schema validation tests |
+| Edge-runtime config | T-FE10 — vitest config with `@edge-runtime/vm` environment |
+
+---
+
+## 10. Test infrastructure (incremental)
+
+| Layer | Tooling | v0.2 additions |
+|-------|---------|----------------|
+| Backend unit | JUnit 5 + Mockito + AssertJ | Same as v0.1 |
+| Backend integration | `@SpringBootTest` + Testcontainers Postgres 16 + **GreenMail** | + GreenMail for mailer ITs |
+| MS Entra mock | **WireMock** or mock-server | New — mocks MS JWKS endpoint |
+| Frontend unit | Vitest + jsdom | Same |
+| Frontend Edge | `@edge-runtime/vm` | New — Edge-runtime test environment |
+| E2E | InspectFlow Playwright suite | + T-E2E07 MS Entra scenario |
+| CI | Gitea Actions `ci.yml` | Runs v0.1 + v0.2 tests on every push |
+
+---
+
+## 11. Open issues / risks for the test plan
+
+| ID | Issue | Mitigation |
+|----|-------|-----------|
+| TR-6 | MS Entra JWKS mock must match real MS token format | Use a test key pair + jose library to sign realistic JWTs. Verify against a real MS Entra test tenant in a manual smoke test before v0.2.0 tag |
+| TR-7 | GreenMail SMTP in Testcontainers adds startup time | Share the GreenMail container across test classes (static container, same as PostgresContainer pattern) |
+| TR-8 | Edge-runtime test environment may not exist for vitest yet | If `@edge-runtime/vm` is unavailable, use `vitest` with `environment: 'edge-runtime'` or fall back to a bundle-analysis test (check the output for Node-only imports) |
+| TR-9 | InspectFlow MS Entra E2E requires a real MS Entra test tenant | Manual smoke test before the automated E2E. The automated test can mock MS Entra for CI; the manual test validates against the real provider |
+| TR-10 | Wire-version bump (if triggered) changes exchange contract mid-sprint | Lock the wire-version decision early (W6-2). If bumped, update T-IT03 + T-IT10..13 to send the correct `wireVersion` field. |
+
+---
+
+## 12. Out of scope for Sprint 1
+
+Deferred to v0.3+:
+
+- Multi-replica nonce store tests (Redis-backed) → v0.3
+- Refresh-token rotation tests (T-SEC10 from v0.1) → v0.3
+- WebAuthn / passkey provider tests → possibly v0.2 stretch
+- Per-app branding tests → deferred
+- Load testing > 1000 req/s → not a v0.x concern
+- Penetration testing (formal) → v1.0
+
+---
+
+## 13. Cross-references
+
+- [Sprint-1-Plan.md](Sprint-1-Plan.md) — implementation plan
+- [Sprint-1-Assessment.md](Sprint-1-Assessment.md) — priority classification + risks
+- [Sprint-0-Testplan.md](Sprint-0-Testplan.md) — v0.1 test plan (regression baseline)
+- [Architecture.md](Architecture.md) — SPI model, wire contract
+- [Roadmap.md](Roadmap.md) — v0.2 scope
+- [Open-Questions.md](Open-Questions.md) — Q02, Q04, Q12 deferrals
+
+---
+
+**End of Sprint-1-Testplan.md (v1).**