diff --git a/Integration-Guide.md b/Integration-Guide.md new file mode 100644 index 0000000..55267cb --- /dev/null +++ b/Integration-Guide.md @@ -0,0 +1,476 @@ +# Integration Guide — Sparkboard ⇆ plate-auth + +**Status:** Draft v1 +**Audience:** Anyone wiring a new plate-software app against plate-auth (Sparkboard is the worked example) +**Last updated:** 2026-06-24 +**Companion to:** [plate-auth/Integration-Guide](https://git.plate-software.de/pplate/plate-auth/wiki/Integration-Guide) + +> This page is the **Sparkboard-specific** walkthrough. It assumes you've read the generic [plate-auth Integration Guide](https://git.plate-software.de/pplate/plate-auth/wiki/Integration-Guide) and shows how the abstract pieces map to Sparkboard's concrete code. + +--- + +## 1. What Sparkboard imports from plate-auth + +Sparkboard depends on exactly two artifacts: + +| Artifact | Version | Where it shows up | +|----------|---------|-------------------| +| `de.platesoft:plate-auth-starter` | `0.1.0` | `backend/pom.xml` | +| `@platesoft/auth` | `0.1.0` | `frontend/package.json` | + +That's it. There is no third artifact. There is no Sparkboard-specific fork. There is no "we patched plate-auth to do X." + +If Sparkboard needs new behaviour that doesn't fit the SPI seams, the change goes **upstream into plate-auth**, gets released as v0.2.0 or v0.1.1, and Sparkboard bumps its dependency. Sparkboard never customises plate-auth in-tree. + +--- + +## 2. The one SPI Sparkboard implements + +Of plate-auth's 5 SPI seams (`OrgValidator`, `OrgDisplayNameResolver`, `InvitationMailer`, `AccessRequestMailer`, `OnboardingHook`), **Sparkboard implements only one**: `OnboardingHook`. + +The other four: + +| SPI | What plate-auth does by default | Why Sparkboard doesn't override | +|-----|--------------------------------|---------------------------------| +| `OrgValidator` | "Allow any org_type" | Sparkboard only has one org_type (`SPARK_ORG`) and one org instance; nothing to validate against. | +| `OrgDisplayNameResolver` | Returns `"Untitled org"` | Sparkboard's single org is hard-coded as "Family Spark" via the seed migration. The resolver is never queried because no UI surfaces multi-org. | +| `InvitationMailer` | No-op (log only) | Sparkboard has no invite UI in v1. | +| `AccessRequestMailer` | No-op (log only) | Sparkboard has no access-request flow. | + +Sparkboard's [`SparkboardOnboardingHook`](Architecture.md#5-single-org-mode--the-onboardinghook-spi) is the **entire** Java auth integration. The full impl sketch: + +```java +// backend/src/main/java/de/plate/sparkboard/auth/SparkboardOnboardingHook.java +package de.plate.sparkboard.auth; + +import de.platesoft.auth.spi.OnboardingHook; +import de.platesoft.auth.model.AuthenticatedUser; +import de.platesoft.auth.service.MembershipService; +import org.springframework.stereotype.Component; +import java.util.UUID; + +@Component +public class SparkboardOnboardingHook implements OnboardingHook { + + public static final UUID FAMILY_SPARK_ID = + UUID.fromString("00000000-0000-0000-0000-000000000001"); + public static final String SPARK_ORG_TYPE = "SPARK_ORG"; + + private final MembershipService memberships; + private final SparkboardAdminProperties adminProps; + + public SparkboardOnboardingHook(MembershipService memberships, + SparkboardAdminProperties adminProps) { + this.memberships = memberships; + this.adminProps = adminProps; + } + + @Override + public void onFirstLogin(AuthenticatedUser user) { + var role = adminProps.admins().contains(user.email()) ? "ADMIN" : "MEMBER"; + memberships.upsert(user.id(), SPARK_ORG_TYPE, FAMILY_SPARK_ID, role); + } +} +``` + +That's the entire SPI footprint. Sparkboard is a `@Component`, plate-auth's auto-config picks it up via Spring's `ObjectProvider`, and the hook fires every login (idempotent thanks to `upsert`). + +--- + +## 3. Backend wire-up — line-by-line + +### 3.1 [`backend/pom.xml`](Sprint-1-Plan.md) excerpt + +```xml + + + org.springframework.boot + spring-boot-starter-parent + 4.1.0 + + + + 25 + 0.1.0 + + + + + gitea-platesoft + https://git.plate-software.de/api/packages/platesoft/maven + + + + + + de.platesoft + plate-auth-starter + ${plate-auth.version} + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.flywaydb + flyway-database-postgresql + + + org.postgresql + postgresql + runtime + + + +``` + +> **Note:** plate-auth transitively brings Spring Security, Resource Server (JWT), and its own Flyway migrations. Sparkboard's `pom.xml` doesn't list those — that's the whole point of the starter. + +### 3.2 [`backend/src/main/resources/application.yml`](Sprint-1-Plan.md) + +The complete plate-auth-relevant config block: + +```yaml +spring: + datasource: + url: jdbc:postgresql://localhost:5432/sparkboard + username: ${DB_USER} + password: ${DB_PASSWORD} + flyway: + enabled: true + locations: classpath:db/migration + # Sparkboard's history table + table: flyway_schema_history + +# plate-auth section +plate: + auth: + base-url: http://localhost:8080 + public-url: https://sparkboard.plate-software.de + jwt: + issuer: https://sparkboard.plate-software.de + audience: sparkboard-frontend + secret: ${PLATE_AUTH_JWT_SECRET} + lifetime: PT12H + oauth: + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + redirect-uri: ${PLATE_AUTH_GOOGLE_REDIRECT_URI:http://localhost:8080/api/auth/callback/google} + allowlist: + - patrick@plate-software.de + - friend@example.com + - kid1@example.com + - kid2@example.com + flyway: + # plate-auth's separate history table — prevents collision with Sparkboard's + history-table: flyway_schema_history_auth + onboarding: + auto-create-membership: false # we handle that in SparkboardOnboardingHook + first-org-type: SPARK_ORG # informs plate-auth which org_type to use if it ever creates one + exchange: + secret: ${PLATE_AUTH_EXCHANGE_SECRET} + +# Sparkboard-specific config +sparkboard: + admins: + - patrick@plate-software.de + - friend@example.com +``` + +> **Key points:** +> - `plate.auth.flyway.history-table` is set to `flyway_schema_history_auth` so the two Flyway histories never collide. +> - `plate.auth.onboarding.auto-create-membership: false` — we explicitly opt-out of plate-auth's built-in membership creation because `SparkboardOnboardingHook` handles it. +> - `sparkboard.admins[]` is a **Sparkboard** property, not a plate-auth property. It's read by `SparkboardAdminProperties`. + +### 3.3 What auto-config wires for Sparkboard + +Sparkboard writes zero lines of Spring Security config. plate-auth auto-config wires: + +| Bean | What it does | +|------|--------------| +| `SecurityFilterChain authChain` | Permits `/api/auth/**`. | +| `SecurityFilterChain apiChain` | Requires authentication for `/api/**`. JWT bearer via Resource Server. | +| `JwtDecoder` | Decodes Sparkboard JWTs using `plate.auth.jwt.secret`. | +| `@CurrentUser` argument resolver | Injects an `AuthenticatedUser` into controllers. | +| Onboarding hook orchestrator | Calls `SparkboardOnboardingHook.onFirstLogin(...)` on first plate-auth login per user. | +| Flyway migrations | Loads from `classpath:db/migration-auth/` into `flyway_schema_history_auth`. | + +Sparkboard's controllers therefore look like this — no auth ceremony at all: + +```java +@RestController +@RequestMapping("/api/ideas") +public class IdeaController { + + @PostMapping + public IdeaDto create(@CurrentUser AuthenticatedUser user, + @Valid @RequestBody CreateIdeaRequest req) { + return service.create(req, user.id(), SparkboardOnboardingHook.FAMILY_SPARK_ID); + } +} +``` + +`@CurrentUser` is **the** integration point. If you see it, you know the request is authenticated and you have the user's identity. + +--- + +## 4. Frontend wire-up — line-by-line + +### 4.1 [`frontend/package.json`](Sprint-1-Plan.md) excerpt + +```json +{ + "name": "sparkboard-frontend", + "version": "0.1.0", + "dependencies": { + "@platesoft/auth": "0.1.0", + "next": "15.0.0", + "react": "19.0.0", + "react-dom": "19.0.0", + "next-auth": "5.0.0" + } +} +``` + +### 4.2 [`frontend/.npmrc`](Sprint-1-Plan.md) + +``` +@platesoft:registry=https://git.plate-software.de/api/packages/platesoft/npm/ +//git.plate-software.de/api/packages/platesoft/npm/:_authToken=${GITEA_NPM_TOKEN} +``` + +`GITEA_NPM_TOKEN` is set in `.env.local` (dev) and in Gitea Actions secrets (CI). + +### 4.3 [`frontend/.env.local`](Sprint-1-Plan.md) excerpt + +``` +NEXT_PUBLIC_BACKEND_URL=http://localhost:8080 +PLATE_AUTH_EXCHANGE_SECRET= +NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_SECRET= +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GITEA_NPM_TOKEN= +``` + +### 4.4 [`frontend/lib/auth.ts`](Sprint-1-Plan.md) + +The factory call. **Sparkboard writes ~15 lines of auth config**: + +```typescript +import { createAuthConfig } from "@platesoft/auth/next-auth"; +import Google from "next-auth/providers/google"; + +export const authConfig = createAuthConfig({ + providers: [Google({ clientId: process.env.GOOGLE_CLIENT_ID!, + clientSecret: process.env.GOOGLE_CLIENT_SECRET! })], + exchange: { + backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL!, + secret: process.env.PLATE_AUTH_EXCHANGE_SECRET!, + }, + pages: { signIn: "/login" }, +}); + +export const { handlers, signIn, signOut, auth } = authConfig; +``` + +That's the **complete** NextAuth v5 setup. No callbacks, no JWT customisation, no signIn handlers. The factory does all of it. + +### 4.5 [`frontend/app/api/auth/[...nextauth]/route.ts`](Sprint-1-Plan.md) + +```typescript +export { GET, POST } from "@/lib/auth"; +``` + +(Yes, one line.) + +### 4.6 [`frontend/app/api/backend/[...path]/route.ts`](Sprint-1-Plan.md) + +The proxy. Sparkboard never lets the browser call the backend directly: + +```typescript +import { createProxyHandlers } from "@platesoft/auth/proxy"; + +const { GET, POST, PUT, DELETE, PATCH } = createProxyHandlers({ + backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL!, +}); + +export { GET, POST, PUT, DELETE, PATCH }; +``` + +### 4.7 [`frontend/middleware.ts`](Sprint-1-Plan.md) + +```typescript +export { middleware, config } from "@platesoft/auth/middleware"; +``` + +The middleware redirects unauthenticated requests away from protected routes to `/login`. + +### 4.8 Optional: React components + +If Sparkboard needs a "Sign in with Google" button, plate-auth ships one: + +```tsx +import { SignInButton } from "@platesoft/auth/components"; + +export default function LoginPage() { + return ( +
+

Sparkboard

+ Sign in with Google +
+ ); +} +``` + +Or roll your own using `signIn` from `lib/auth.ts`. + +--- + +## 5. Database setup + +### 5.1 Two histories, one schema + +``` +postgres +└── sparkboard (database) + ├── flyway_schema_history (Sparkboard's migrations) + │ └── tracks: spark_org, ideas, ... + └── flyway_schema_history_auth (plate-auth's migrations) + └── tracks: auth_identities, memberships, ... +``` + +Both tables sit in the same Postgres database. Both are managed by Flyway. They don't reference each other. + +### 5.2 What plate-auth creates + +- `auth_identities (user_id PK, email, provider, ...)` — user identities. +- `memberships (user_id, org_type, org_id, role, ...)` — polymorphic membership pivot. +- `invitations`, `access_requests` — empty for Sparkboard (no UI uses them). + +### 5.3 What Sparkboard creates + +- `spark_org (id PK, name, org_type, created_at)` — seeded with one row. +- `ideas (id PK, author_id, org_id, title, body, status, created_at, updated_at)`. + +### 5.4 No cross-history foreign keys + +**Important:** Sparkboard's `ideas.author_id` is NOT declared as a FK to `auth_identities.user_id`. + +**Why:** +- The two Flyway histories run independently. If plate-auth's `V1__auth_identities.sql` hasn't run yet when Sparkboard's `V1__init.sql` runs, the FK declaration fails. +- Referential integrity is enforced at the application layer (controllers always inject `@CurrentUser`, which guarantees a valid `user_id`). +- Trade-off: theoretical risk of orphan `ideas` rows if a `auth_identities` row is deleted. For 4 humans, the risk is zero in practice. + +--- + +## 6. Full sign-in flow walkthrough (Sparkboard concrete) + +1. Patrick navigates to `https://sparkboard.plate-software.de`. +2. Sparkboard middleware sees no session → redirects to `/login`. +3. `/login` renders the `SignInButton`. +4. Patrick clicks it → NextAuth-Google OAuth dance → callback to `/api/auth/callback/google`. +5. NextAuth verifies the Google token, looks up the email `patrick@plate-software.de`. +6. The signIn callback (configured by `createAuthConfig`) hashes the email with `PLATE_AUTH_EXCHANGE_SECRET` and POSTs an envelope to `POST /api/auth/exchange` on the backend. +7. Backend's plate-auth verifies the HMAC, checks the allowlist (`patrick@plate-software.de` is in it), looks up or inserts an `auth_identities` row. +8. **If this is the first login**, plate-auth's onboarding orchestrator calls `SparkboardOnboardingHook.onFirstLogin(user)`. +9. Hook checks `sparkboard.admins[]` → `patrick@plate-software.de` is there → calls `MembershipService.upsert(userId, "SPARK_ORG", FAMILY_SPARK_ID, "ADMIN")`. +10. plate-auth issues a JWT, signs it with `plate.auth.jwt.secret`, returns it in the exchange response. +11. NextAuth stores it in the session cookie. +12. Frontend redirects to `/ideas`. +13. `/ideas` server component calls `listIdeas()` which calls `fetch("http://backend:8080/api/ideas", { headers: { Cookie } })`. +14. Backend's `SecurityFilterChain` extracts the JWT from the session-cookie-carried bearer, verifies it via `JwtDecoder`, populates `@CurrentUser`. +15. `IdeaController.list(@CurrentUser, FAMILY_SPARK_ID)` returns the ideas for Family Spark. + +Steps 1, 12, 13 are pure Sparkboard. Steps 2–11, 14 are pure plate-auth. Sparkboard never touched a security filter or a JWT. + +--- + +## 7. What to do when… + +### 7.1 …a new family member arrives + +1. Add their email to `plate.auth.allowlist[]` in `application.yml`. +2. (Optional) add to `sparkboard.admins[]` if they should be ADMIN. +3. `git push origin main` → Gitea Actions deploys. +4. New member signs in via Google → `SparkboardOnboardingHook` auto-creates their membership. + +No DB edits, no manual steps. + +### 7.2 …plate-auth ships v0.2.0 with a new feature + +1. Bump `plate-auth.version` in `pom.xml` and `@platesoft/auth` in `package.json`. +2. Read plate-auth's CHANGELOG. +3. Adjust any breaking changes (semver minor should mean no breaks, but read). +4. Sparkboard tests should still pass; CI verifies. + +### 7.3 …Sparkboard needs an auth feature plate-auth doesn't have + +1. **Don't** fork plate-auth in-tree. +2. File an issue on the plate-auth repo describing the use case. +3. Either: + - plate-auth adds a new SPI seam (Sparkboard implements it). + - plate-auth ships the feature behind a `plate.auth.*` config flag (Sparkboard enables it). +4. Sparkboard bumps to the new plate-auth version. + +This protects both projects from divergence. + +### 7.4 …an exchange envelope verification fails + +Most likely cause: `PLATE_AUTH_EXCHANGE_SECRET` differs between frontend `.env.local` and backend `application.yml`. Both must be identical. Symptom: 401 immediately after Google OAuth callback. + +### 7.5 …Flyway complains about migration history + +Likely cause: someone added a Sparkboard migration to `db/migration-auth/` by accident. Sparkboard's migrations go in `db/migration/`. plate-auth's are inside its starter JAR; never put migration files for plate-auth in Sparkboard's repo. + +--- + +## 8. Verification checklist (after first deploy) + +Run these in order. If any fails, stop and fix before continuing. + +- [ ] `docker compose ps` shows postgres, backend, frontend, caddy all healthy. +- [ ] `psql -U sparkboard -c "\dt"` lists both `flyway_schema_history` AND `flyway_schema_history_auth`. +- [ ] `psql` confirms `spark_org` has one row with id `00000000-0000-0000-0000-000000000001`. +- [ ] `curl https://sparkboard.plate-software.de/api/health` returns 200. +- [ ] Browser loads `https://sparkboard.plate-software.de/login` and shows "Sign in with Google". +- [ ] Patrick signs in → reaches `/ideas` (empty list). +- [ ] `psql -c "SELECT * FROM memberships;"` shows one row with `(patrick_user_id, 'SPARK_ORG', '00...01', 'ADMIN')`. +- [ ] Patrick creates an idea → it appears on `/ideas`. + +--- + +## 9. Common pitfalls (Sparkboard-specific) + +| Pitfall | Symptom | Fix | +|---------|---------|-----| +| Forgetting `plate.auth.flyway.history-table` | Flyway error: duplicate migration version | Set it to `flyway_schema_history_auth` | +| Using `plate.auth.onboarding.auto-create-membership: true` AND `SparkboardOnboardingHook` | Two memberships per user | Set the property to `false` | +| Setting `org_id` to the wrong UUID in IdeaController | `GET /api/ideas` returns `[]` even though ideas exist | Verify `FAMILY_SPARK_ID = UUID.fromString("00000000-0000-0000-0000-000000000001")` | +| Hardcoded `Authorization: Bearer ...` in fetch from server component | Works locally, breaks in prod | Always forward `cookies()` from `next/headers` instead | +| Migrating with `idea.author_id` FK to `auth_identities` | Migration fails because plate-auth tables don't exist yet at migration time | Drop the FK; rely on app-layer integrity | +| Different `PLATE_AUTH_EXCHANGE_SECRET` on frontend vs backend | 401 right after Google callback | Set both from the same secret store | +| `sparkboard.admins[]` email case mismatch | All users get MEMBER role | Normalise to lowercase in `SparkboardOnboardingHook` | + +--- + +## 10. Cross-references + +- [Sprint-1-Plan](Sprint-1-Plan.md) chunks 1–4 — the work that produces these files +- [Architecture](Architecture.md) — diagrams of the wire flow +- [Sprint-1-Assessment](Sprint-1-Assessment.md) §10 — plate-auth integration footprint table +- [Open-Questions](Open-Questions.md) — Q01 (single-org), Q03 (admin promotion), Q06 (security customisation) +- plate-auth wiki: + - [plate-auth/Integration-Guide](https://git.plate-software.de/pplate/plate-auth/wiki/Integration-Guide) — generic version + - [plate-auth/Architecture](https://git.plate-software.de/pplate/plate-auth/wiki/Architecture) — what plate-auth ships + - [plate-auth/Roadmap](https://git.plate-software.de/pplate/plate-auth/wiki/Roadmap) — what's coming in v0.2, v0.3 + +--- + +_End of Integration Guide. Status: **Draft v1, awaiting GO from Patrick.**_