Table of Contents
- Integration Guide — Sparkboard ⇆ plate-auth
- 1. What Sparkboard imports from plate-auth
- 2. The one SPI Sparkboard implements
- 3. Backend wire-up — line-by-line
- 3.1 backend/pom.xml excerpt
- 3.2 backend/src/main/resources/application.yml
- 3.3 What auto-config wires for Sparkboard
- 4. Frontend wire-up — line-by-line
- 4.1 frontend/package.json excerpt
- 4.2 frontend/.npmrc
- 4.3 frontend/.env.local excerpt
- 4.4 frontend/lib/auth.ts
- 4.5 frontend/app/api/auth/[...nextauth]/route.ts
- 4.6 frontend/app/api/backend/[...path]/route.ts
- 4.7 frontend/middleware.ts
- 4.8 Optional: React components
- 5. Database setup
- 5.1 Two histories, one schema
- 5.2 What plate-auth creates
- 5.3 What Sparkboard creates
- 5.4 No cross-history foreign keys
- 6. Full sign-in flow walkthrough (Sparkboard concrete)
- 7. What to do when…
- 7.1 …a new family member arrives
- 7.2 …plate-auth ships v0.2.0 with a new feature
- 7.3 …Sparkboard needs an auth feature plate-auth doesn't have
- 7.4 …an exchange envelope verification fails
- 7.5 …Flyway complains about migration history
- 8. Verification checklist (after first deploy)
- 9. Common pitfalls (Sparkboard-specific)
- 10. Cross-references
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
This page is the Sparkboard-specific walkthrough. It assumes you've read the generic plate-auth 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 is the entire Java auth integration. The full impl sketch:
// backend/src/main/java/de/plate/sparkboard/onboarding/SparkboardOnboardingHook.java
package de.plate.sparkboard.onboarding;
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 onFirstSignIn(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<OnboardingHook>, and the hook fires every login (idempotent thanks to upsert).
3. Backend wire-up — line-by-line
3.1 backend/pom.xml excerpt
<project>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
</parent>
<properties>
<java.version>25</java.version>
<plate-auth.version>0.1.0</plate-auth.version>
</properties>
<repositories>
<repository>
<id>gitea-platesoft</id>
<url>https://git.plate-software.de/api/packages/platesoft/maven</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>de.platesoft</groupId>
<artifactId>plate-auth-starter</artifactId>
<version>${plate-auth.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
</project>
Note: plate-auth transitively brings Spring Security, Resource Server (JWT), and its own Flyway migrations. Sparkboard's
pom.xmldoesn't list those — that's the whole point of the starter.
3.2 backend/src/main/resources/application.yml
The complete plate-auth-relevant config block:
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-tableis set toflyway_schema_history_authso 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 becauseSparkboardOnboardingHookhandles it.sparkboard.admins[]is a Sparkboard property, not a plate-auth property. It's read bySparkboardAdminProperties.
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.onFirstSignIn(...) on first plate-auth sign-in 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:
@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 excerpt
{
"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
@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 excerpt
NEXT_PUBLIC_BACKEND_URL=http://localhost:8080
PLATE_AUTH_EXCHANGE_SECRET=<same as backend>
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=<random 32 bytes base64>
GOOGLE_CLIENT_ID=<your-client-id>
GOOGLE_CLIENT_SECRET=<your-secret>
GITEA_NPM_TOKEN=<your-token>
4.4 frontend/lib/auth.ts
The factory call. Sparkboard writes ~15 lines of auth config:
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
export { GET, POST } from "@/lib/auth";
(Yes, one line.)
4.6 frontend/app/api/backend/[...path]/route.ts
The proxy. Sparkboard never lets the browser call the backend directly:
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
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:
import { SignInButton } from "@platesoft/auth/components";
export default function LoginPage() {
return (
<div>
<h1>Sparkboard</h1>
<SignInButton provider="google">Sign in with Google</SignInButton>
</div>
);
}
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.sqlhasn't run yet when Sparkboard'sV1__init.sqlruns, the FK declaration fails. - Referential integrity is enforced at the application layer (controllers always inject
@CurrentUser, which guarantees a validuser_id). - Trade-off: theoretical risk of orphan
ideasrows if aauth_identitiesrow is deleted. For 4 humans, the risk is zero in practice.
6. Full sign-in flow walkthrough (Sparkboard concrete)
- Patrick navigates to
https://sparkboard.plate-software.de. - Sparkboard middleware sees no session → redirects to
/login. /loginrenders theSignInButton.- Patrick clicks it → NextAuth-Google OAuth dance → callback to
/api/auth/callback/google. - NextAuth verifies the Google token, looks up the email
patrick@plate-software.de. - The signIn callback (configured by
createAuthConfig) hashes the email withPLATE_AUTH_EXCHANGE_SECRETand POSTs an envelope toPOST /api/auth/exchangeon the backend. - Backend's plate-auth verifies the HMAC, checks the allowlist (
patrick@plate-software.deis in it), looks up or inserts anauth_identitiesrow. - If this is the first sign-in, plate-auth's onboarding orchestrator calls
SparkboardOnboardingHook.onFirstSignIn(user). - Hook checks
sparkboard.admins[]→patrick@plate-software.deis there → callsMembershipService.upsert(userId, "SPARK_ORG", FAMILY_SPARK_ID, "ADMIN"). - plate-auth issues a JWT, signs it with
plate.auth.jwt.secret, returns it in the exchange response. - NextAuth stores it in the session cookie.
- Frontend redirects to
/ideas. /ideasserver component callslistIdeas()which callsfetch("http://backend:8080/api/ideas", { headers: { Cookie } }).- Backend's
SecurityFilterChainextracts the JWT from the session-cookie-carried bearer, verifies it viaJwtDecoder, populates@CurrentUser. 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
- Add their email to
plate.auth.allowlist[]inapplication.yml. - (Optional) add to
sparkboard.admins[]if they should be ADMIN. git push origin main→ Gitea Actions deploys.- New member signs in via Google →
SparkboardOnboardingHookauto-creates their membership.
No DB edits, no manual steps.
7.2 …plate-auth ships v0.2.0 with a new feature
- Bump
plate-auth.versioninpom.xmland@platesoft/authinpackage.json. - Read plate-auth's CHANGELOG.
- Adjust any breaking changes (semver minor should mean no breaks, but read).
- Sparkboard tests should still pass; CI verifies.
7.3 …Sparkboard needs an auth feature plate-auth doesn't have
- Don't fork plate-auth in-tree.
- File an issue on the plate-auth repo describing the use case.
- 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).
- 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 psshows postgres, backend, frontend, caddy all healthy.psql -U sparkboard -c "\dt"lists bothflyway_schema_historyANDflyway_schema_history_auth.psqlconfirmsspark_orghas one row with id00000000-0000-0000-0000-000000000001.curl https://sparkboard.plate-software.de/api/healthreturns 200.- Browser loads
https://sparkboard.plate-software.de/loginand 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 chunks 1–4 — the work that produces these files
- Architecture — diagrams of the wire flow
- Sprint-1-Assessment §10 — plate-auth integration footprint table
- Open-Questions — Q01 (single-org), Q03 (admin promotion), Q06 (security customisation)
- plate-auth wiki:
- plate-auth/Integration-Guide — generic version
- plate-auth/Architecture — what plate-auth ships
- plate-auth/Roadmap — what's coming in v0.2, v0.3
End of Integration Guide. Status: Draft v1, awaiting GO from Patrick.