Table of Contents
- Architecture
- 1. System shape
- 2. Two artifacts that Sparkboard depends on
- 3. Tier model — what Sparkboard owns
- 4. Single-org mode and OnboardingHook
- 4.1 The single org
- 4.2 The polymorphic FK contract
- 4.3 SparkboardOnboardingHook
- 4.4 What the hook does NOT do
- 5. Sparkboard domain
- 6. End-to-end sign-in flow
- 7. Request → API flow (authenticated)
- 8. Package layout (backend)
- 9. Package layout (frontend)
- 10. Configuration
- 11. Database
- 12. Deployment
- 13. Threat model summary
- 14. What this document is not
Architecture
Sparkboard is a greenfield consumer of plate-auth. Everything in this document is about how Sparkboard wires itself into that library, what it adds on top, and what it deliberately leaves out.
If you have not already read plate-auth — Architecture, read that first. This document assumes you know the tier model (T1 auth core, T2 multi-tenancy, T3 domain) and the SPI seams.
1. System shape
┌─────────────────────────────────────────────────────────┐
│ sparkboard.plate-software.de │
│ (IONOS Apache + Let's Encrypt) │
└─────────────────────────────────────────────────────────┘
│ TLS
▼
┌─────────────────────────────────────────────────────────┐
│ frps tunnel (TrueNAS) — port 30011 — frpc client │
└─────────────────────────────────────────────────────────┘
│
┌─────────────────────────┴───────────────────────────────┐
│ TrueNAS Docker network │
│ │
│ ┌────────────────┐ / ┌─────────────────┐ │
│ │ frontend │ /api/* │ backend │ │
│ │ Next.js 15 │───────────────▶│ Spring Boot 4.1 │ │
│ │ + NextAuth v5 │ Bearer JWT │ + plate-auth │ │
│ │ + @platesoft/ │ (issued by │ + Sparkboard │ │
│ │ auth/proxy │ plate-auth) │ domain │ │
│ └────────────────┘ └──────────┬──────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Postgres 16 │ │
│ │ (single DB) │ │
│ └──────────────┘ │
└──────────────────────────────────────────────────────────┘
│
▼
Google OAuth 2.0
(Sprint 1: Google only)
The Spring Boot backend hosts both plate-auth's endpoints (/api/auth/**, /api/me, /api/memberships/**) and Sparkboard's domain endpoints (/api/ideas/**). Both sets of endpoints live in the same JAR, mounted by the same DispatcherServlet, signed by the same backend JWT.
There is no separate "auth service". Plate-auth is a library, not a microservice. Sparkboard is one process.
2. Two artifacts that Sparkboard depends on
| Artifact | What | Pulled from |
|---|---|---|
de.platesoft:plate-auth-starter:0.1.0 |
Spring Boot 4.1 starter — auto-configures everything in T1 + T2 | Gitea Maven registry at git.plate-software.de |
@platesoft/auth@0.1.0 |
npm package — NextAuth v5 factory + proxy handler + React hooks | Gitea npm registry at git.plate-software.de |
Both ship lockstep at v0.1.0. See plate-auth Roadmap for versioning policy.
Sparkboard does not vendor, copy, or fork any code from plate-auth. It depends on the published artifacts. Period.
3. Tier model — what Sparkboard owns
Plate-auth's tier model maps onto Sparkboard like this:
| Tier | Owner | Sparkboard's involvement |
|---|---|---|
| T1 — Auth core (sessions, JWT, exchange, providers, allowlist) | plate-auth | Configure via plate.auth.* properties only. Zero code. |
T2 — Multi-tenancy (memberships, invitations, access-requests) |
plate-auth | Configure for single-org mode: hide invitation UI, disable access-request flow. Membership table is real and populated. |
| T3 — Consumer onboarding + domain | Sparkboard | Implement SparkboardOnboardingHook. Implement the Idea domain. |
Everything else is "left as default" — Sparkboard does not implement OrgValidator, OrgDisplayNameResolver, InvitationMailer, or AccessRequestMailer SPIs in Sprint 1. They are not needed when there's exactly one org and no invitations.
4. Single-org mode and OnboardingHook
Sparkboard is the canonical "single-org consumer" pattern for plate-auth. It is what the plate-auth Integration Guide describes as the minimum-viable case.
4.1 The single org
One row, seeded by Flyway, in the memberships table — actually in a tiny spark_org table that Sparkboard owns:
-- backend/src/main/resources/db/migration/V2__seed_family_spark_org.sql
CREATE TABLE spark_org (
id UUID PRIMARY KEY,
name VARCHAR(80) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO spark_org (id, name) VALUES
('00000000-0000-0000-0000-000000000001', 'Family Spark');
The org_id 00000000-0000-0000-0000-000000000001 is a magic constant. There is exactly one. See Open Question Q01 for the discussion about whether it should be configurable.
4.2 The polymorphic FK contract
Plate-auth's memberships table has the polymorphic shape:
memberships:
user_id UUID NOT NULL → auth_identities.user_id
org_type VARCHAR NOT NULL -- discriminator, set by consumer
org_id UUID NOT NULL -- validated by OrgValidator SPI
role VARCHAR NOT NULL -- 'ADMIN' | 'MEMBER'
PRIMARY KEY (user_id, org_type, org_id)
For Sparkboard, every row uses org_type = 'SPARK_ORG' and org_id = 00000000-0000-0000-0000-000000000001.
Because Sparkboard has exactly one org and trusts itself, it accepts plate-auth's default no-op OrgValidator in Sprint 1. (A future sprint may implement a strict OrgValidator that checks spark_org.id exists — but it's a 5-line implementation; not worth doing in Sprint 1.)
4.3 SparkboardOnboardingHook
This is the one SPI bean Sparkboard implements. It's how a newly-signed-in Google user gets a membership row.
// 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.membership.MembershipService;
import de.platesoft.auth.membership.Role;
import org.springframework.stereotype.Component;
import java.util.UUID;
@Component
public class SparkboardOnboardingHook implements OnboardingHook {
public static final String ORG_TYPE = "SPARK_ORG";
public static final UUID FAMILY_SPARK_ID =
UUID.fromString("00000000-0000-0000-0000-000000000001");
private final MembershipService memberships;
private final SparkboardAdminProperties admins;
public SparkboardOnboardingHook(MembershipService memberships,
SparkboardAdminProperties admins) {
this.memberships = memberships;
this.admins = admins;
}
@Override
public void onFirstSignIn(AuthenticatedUser user) {
Role role = admins.isAdminEmail(user.email()) ? Role.ADMIN : Role.MEMBER;
memberships.upsert(user.id(), ORG_TYPE, FAMILY_SPARK_ID, role);
}
}
SparkboardAdminProperties reads sparkboard.admins[] from application.yml:
sparkboard:
admins:
- patrick@plate-software.de
- <friend's email>
Everyone else who passes plate-auth's allowlist becomes a MEMBER.
4.4 What the hook does NOT do
- It does not create the org. The org is Flyway-seeded once.
- It does not decide allowlisting. That is plate-auth's
plate.auth.allowlistconfig. - It does not send a welcome email. (Sprint 4+ candidate.)
- It does not create any Sparkboard-domain data. (No "default idea" on first login.)
It is idempotent: memberships.upsert is a INSERT … ON CONFLICT DO NOTHING. Re-running it on every login is fine and is the simplest implementation. Plate-auth promises to call it on first login only, but Sparkboard does not rely on that promise being bug-free.
5. Sparkboard domain
In Sprint 1, Sparkboard owns exactly one domain table: ideas.
5.1 ER diagram (Sprint 1)
erDiagram
auth_identities ||--o{ memberships : "has"
spark_org ||--o{ memberships : "has (polymorphic, org_type='SPARK_ORG')"
auth_identities ||--o{ ideas : "authored"
spark_org ||--o{ ideas : "scoped to (always Family Spark in v1)"
auth_identities {
UUID user_id PK
VARCHAR email
VARCHAR display_name
TIMESTAMPTZ created_at
}
memberships {
UUID user_id PK,FK
VARCHAR org_type PK
UUID org_id PK
VARCHAR role
TIMESTAMPTZ created_at
}
spark_org {
UUID id PK
VARCHAR name
TIMESTAMPTZ created_at
}
ideas {
UUID id PK
UUID org_id FK
UUID author_id FK
VARCHAR title
TEXT description
VARCHAR status
TIMESTAMPTZ created_at
TIMESTAMPTZ updated_at
TIMESTAMPTZ archived_at
}
The
auth_identities,memberships,invitations, andaccess_requeststables are owned and migrated by plate-auth. Sparkboard does not touch them. Plate-auth tracks its own Flyway state inflyway_schema_history_auth; Sparkboard tracks its own inflyway_schema_history.
5.2 Idea entity (Java)
// backend/src/main/java/de/plate/sparkboard/idea/Idea.java
@Entity
@Table(name = "ideas")
public class Idea {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "org_id", nullable = false)
private UUID orgId; // always FAMILY_SPARK_ID in v1
@Column(name = "author_id", nullable = false)
private UUID authorId; // plate-auth user_id
@Column(nullable = false, length = 200)
private String title;
@Column(columnDefinition = "TEXT")
private String description;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private IdeaStatus status = IdeaStatus.RAW;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Column(name = "archived_at")
private Instant archivedAt;
// getters, setters, lifecycle callbacks omitted
}
public enum IdeaStatus { RAW, EXPLORING, BUILDING, SHIPPED, DEAD }
In Sprint 1, only RAW is ever set. Status transitions ship in Sprint 2.
5.3 Why org_id is on every idea row
Even though there is only one org in v1, every Idea carries an org_id. This is deliberate forward-compatibility:
- If a future Sparkboard ever supports multiple boards (e.g., "Family Spark" + "Work Spark"), the data model already handles it.
- Read queries always filter by
WHERE org_id = ?— the constant is read from the authenticated user's membership row. - It costs one column and one index. It is cheap insurance.
See Open Question Q01.
6. End-to-end sign-in flow
This is the same flow plate-auth describes — Sparkboard adds zero steps. It is included here so a Sparkboard reader doesn't need to context-switch wikis.
sequenceDiagram
autonumber
participant U as User (browser)
participant FE as Next.js / NextAuth
participant G as Google OAuth
participant BE as Spring Boot + plate-auth + Sparkboard
participant DB as Postgres
U->>FE: GET /login
FE->>G: redirect to Google consent
G-->>FE: ?code=...
FE->>FE: NextAuth signIn callback
Note over FE: @platesoft/auth/next-auth factory
FE->>BE: POST /api/auth/exchange (HMAC-signed envelope: provider, providerId, email, name)
BE->>BE: plate-auth verifies HMAC + provider
BE->>BE: plate-auth checks allowlist
BE->>DB: SELECT / INSERT auth_identities
Note over BE,DB: First login? → fire OnboardingHook
BE->>BE: SparkboardOnboardingHook.onFirstSignIn
BE->>DB: INSERT memberships (user_id, 'SPARK_ORG', FAMILY_SPARK_ID, role)
BE-->>FE: { userId, accessToken (15m JWT), refreshToken }
FE->>FE: NextAuth session populated
FE-->>U: redirect to /ideas
Sparkboard contributes exactly one step: step 8 (the hook). Everything else is plate-auth.
7. Request → API flow (authenticated)
sequenceDiagram
participant U as User
participant FE as Next.js (App Router)
participant Proxy as /api/backend/[...path] (Sparkboard)
participant BE as Spring Boot
U->>FE: GET /ideas (server component)
FE->>FE: const session = await auth()
FE->>Proxy: fetch('/api/backend/api/ideas', { cache: 'no-store' })
Note over Proxy: createProxyHandlers from @platesoft/auth/proxy
Proxy->>Proxy: Inject Authorization: Bearer <session.accessToken>
Proxy->>BE: GET /api/ideas
BE->>BE: plate-auth JwtAuthFilter validates token
BE->>BE: SecurityContext = { userId, email, memberships }
BE->>BE: IdeaController.list(authenticatedUser)
BE->>BE: ideaRepo.findByOrgIdOrderByCreatedAtDesc(FAMILY_SPARK_ID)
BE-->>Proxy: 200 OK + JSON
Proxy-->>FE: 200 OK + JSON
FE-->>U: HTML with idea list
Sparkboard's proxy route is a single line thanks to @platesoft/auth/proxy:
// frontend/app/api/backend/[...path]/route.ts
export const { GET, POST, PUT, PATCH, DELETE } = createProxyHandlers({
backendUrl: process.env.PLATE_AUTH_BACKEND_URL!,
exchangeSecret: process.env.PLATE_AUTH_EXCHANGE_SECRET!,
});
This replaces the ~50 lines of hand-rolled proxy code that InspectFlow had (and CannaManage copy-pasted).
8. Package layout (backend)
backend/
├── pom.xml
└── src/main/java/de/plate/sparkboard/
├── SparkboardApplication.java
├── onboarding/
│ ├── SparkboardOnboardingHook.java
│ └── SparkboardAdminProperties.java
├── idea/
│ ├── Idea.java
│ ├── IdeaStatus.java
│ ├── IdeaRepository.java
│ ├── IdeaService.java
│ ├── IdeaController.java
│ ├── IdeaDto.java
│ └── CreateIdeaRequest.java
└── config/
└── SparkboardSecurityConfig.java // optional — plate-auth's default may suffice
Notably absent (because plate-auth owns them):
- No
User/UserRepository/UserService - No
JwtAuthFilter/JwtUtil/JwtAuthenticationToken - No
SecurityConfigfor auth wiring (plate-auth ships one; Sparkboard may extend it for/api/ideas/**if needed but Spring's default URL-pattern matching usually suffices) - No
AllowlistProperties/ allowlist endpoint - No
AuthExchangeController - No membership / invitation / access-request controllers
That deletion list is the value proposition of plate-auth, made visible.
9. Package layout (frontend)
frontend/
├── package.json (depends on @platesoft/auth: 0.1.0)
├── next.config.ts
├── auth.ts // NextAuth v5 factory call
├── middleware.ts // re-export from @platesoft/auth/middleware (optional)
└── app/
├── api/
│ ├── auth/[...nextauth]/route.ts // export { handlers as GET, handlers as POST } = handlers
│ └── backend/[...path]/route.ts // createProxyHandlers(...)
├── (auth)/login/page.tsx
└── (app)/
├── layout.tsx
├── ideas/
│ ├── page.tsx // list (server component)
│ ├── new/page.tsx // create form
│ └── [id]/page.tsx // detail (Sprint 2)
└── components/
├── idea-form.tsx
└── idea-list.tsx
There is no hand-rolled lib/auth.ts doing NextAuth config. The factory pattern from @platesoft/auth/next-auth collapses it to ~10 lines. See Integration Guide.
10. Configuration
10.1 Backend application.yml
spring:
application:
name: sparkboard
datasource:
url: jdbc:postgresql://${DB_HOST:postgres}:${DB_PORT:5432}/${DB_NAME:sparkboard}
username: ${DB_USER:sparkboard}
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate
properties:
hibernate.dialect: org.hibernate.dialect.PostgreSQLDialect
flyway:
enabled: true
locations: classpath:db/migration
plate:
auth:
jwt:
secret: ${PLATE_AUTH_JWT_SECRET}
access-expiration: PT15M
refresh-expiration: P30D
exchange:
secret: ${PLATE_AUTH_EXCHANGE_SECRET}
registration:
enabled: false # disabled — single-org allowlist mode
allowlist:
enabled: true
emails:
- patrick@plate-software.de
- <friend@example.com>
- <son1@example.com>
- <son2@example.com>
providers:
google:
enabled: true
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
sparkboard:
admins:
- patrick@plate-software.de
- <friend@example.com>
All seven plate-auth properties above are documented in plate-auth Architecture §3.3.
10.2 Frontend .env.local
NEXTAUTH_SECRET=...
NEXTAUTH_URL=https://sparkboard.plate-software.de
PLATE_AUTH_BACKEND_URL=http://backend:8080
PLATE_AUTH_EXCHANGE_SECRET=... # same secret as backend's plate.auth.exchange.secret
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
11. Database
- Engine: Postgres 16, single instance, single database called
sparkboard. - Migrations: two Flyway histories side-by-side:
flyway_schema_history— Sparkboard's own (tableideas,spark_org)flyway_schema_history_auth— plate-auth's (tablesauth_identities,memberships,invitations,access_requests)
- No H2. Not even in tests. Tests use Testcontainers Postgres.
This is a hard departure from InspectFlow's earlier H2-first stance. Plate-auth ships Postgres-only schemas; mixing H2 in is not worth the maintenance cost for a 4-user app.
12. Deployment
Same shape as InspectFlow and CannaManage. See Sprint-1-Plan §W6 for the actual files.
| Component | Where | How |
|---|---|---|
| TLS termination | IONOS Apache (sparkboard.plate-software.de) |
Existing wildcard cert |
| Tunnel | frps on TrueNAS, frpc on the IONOS box, port 30011 |
New port per plate-software app |
| Frontend | TrueNAS Docker, Next.js standalone build | pnpm build && node server.js |
| Backend | TrueNAS Docker, Spring Boot fat JAR | java -jar app.jar |
| Database | TrueNAS Docker, Postgres 16 image | Persistent volume on TrueNAS pool |
| CI | Gitea Actions in sparkboard repo |
Build → push images to Gitea registry → SSH-deploy script |
Port allocations across plate-software:
| App | frpc port |
|---|---|
| InspectFlow | 30009 |
| CannaManage | 30010 |
| Sparkboard | 30011 |
| plate-auth (if ever self-hosted as a demo) | 30012 |
13. Threat model summary
Inherited from plate-auth wholesale — see plate-auth Architecture §10.
Sparkboard-specific additions:
| Risk | Mitigation |
|---|---|
| Idea is visible to a user not in the family org | All Idea reads filter on org_id; the authenticated user's memberships row pins the org. |
| 5th Google account leaks in | plate.auth.allowlist.emails enforced server-side by plate-auth; allowlist is a hardcoded list of four addresses in v1 (see Open Question Q02). |
| Token reuse after admin removes a user | Out of scope for v1 (no admin UI). Mitigated operationally by short access-token expiration (15 min) and the fact that there is no remove-user flow yet. |
14. What this document is not
- Not a re-explanation of plate-auth internals — read plate-auth Architecture for that.
- Not a step-by-step setup guide — see Integration Guide for the Sparkboard-flavoured walkthrough.
- Not a description of Sprint 2+ design (reactions, comments, tags) — see Roadmap.