docs(architecture): plate-auth consumer model — single-org SPARK_ORG, OnboardingHook, polymorphic FK

Patrick Plate
2026-06-24 14:45:26 +02:00
parent b25de274d2
commit f5ff3383a2
+540
@@ -0,0 +1,540 @@
# Architecture
Sparkboard is a **greenfield consumer of [plate-auth](https://git.plate-software.de/pplate/plate-auth/wiki)**. 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](https://git.plate-software.de/pplate/plate-auth/wiki/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](https://git.plate-software.de/pplate/plate-auth/wiki/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`](#single-org-mode-and-onboardinghook). Implement the [`Idea` domain](#5-sparkboard-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](https://git.plate-software.de/pplate/plate-auth/wiki/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:
```sql
-- 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](Open-Questions.md#q01-single-org-name-and-id-strategy) 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.
```java
// 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.spi.OnboardingContext;
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 afterFirstLogin(OnboardingContext ctx) {
Role role = admins.isAdminEmail(ctx.email()) ? Role.ADMIN : Role.MEMBER;
memberships.upsert(ctx.userId(), ORG_TYPE, FAMILY_SPARK_ID, role);
}
}
```
`SparkboardAdminProperties` reads `sparkboard.admins[]` from `application.yml`:
```yaml
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.allowlist` config.
- 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)
```mermaid
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`, and `access_requests` tables are **owned and migrated by plate-auth**. Sparkboard does not touch them. Plate-auth tracks its own Flyway state in [`flyway_schema_history_auth`](https://git.plate-software.de/pplate/plate-auth/wiki/Architecture#9-distribution); Sparkboard tracks its own in `flyway_schema_history`.
### 5.2 Idea entity (Java)
```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](Open-Questions.md#q01-single-org-name-and-id-strategy).
---
## 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.
```mermaid
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.afterFirstLogin
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)
```mermaid
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`:
```typescript
// 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 `SecurityConfig` for 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](Integration-Guide.md#21-nextauthts-factory-wire-up).
---
## 10. Configuration
### 10.1 Backend `application.yml`
```yaml
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](https://git.plate-software.de/pplate/plate-auth/wiki/Architecture#33-configuration-properties-plateauth).
### 10.2 Frontend `.env.local`
```bash
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 (table `ideas`, `spark_org`)
- `flyway_schema_history_auth` — plate-auth's (tables `auth_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](Sprint-1-Plan.md) 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](https://git.plate-software.de/pplate/plate-auth/wiki/Architecture#10-threat-model-summary).
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](Open-Questions.md#q02-allowlist-management)). |
| 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](https://git.plate-software.de/pplate/plate-auth/wiki/Architecture) for that.
- Not a step-by-step setup guide — see [Integration Guide](Integration-Guide.md) for the Sparkboard-flavoured walkthrough.
- Not a description of Sprint 2+ design (reactions, comments, tags) — see [Roadmap](Roadmap.md).