diff --git a/Architecture.md b/Architecture.md new file mode 100644 index 0000000..9a1ce8e --- /dev/null +++ b/Architecture.md @@ -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 + - +``` + +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 + 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 + - + - + - + providers: + google: + enabled: true + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + +sparkboard: + admins: + - patrick@plate-software.de + - +``` + +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).