2
Architecture
Patrick Plate edited this page 2026-06-24 15:28:48 +02:00

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.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)

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; Sparkboard tracks its own in flyway_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 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.


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 (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 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.