From 9c4c3d644471dd511b5fdf083106335ab16de2e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 19 Apr 2026 07:46:34 +0200 Subject: [PATCH] Store Terms version alongside acceptance timestamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer's follow-up: it's not enough to record when a user accepted the Terms; we also need to record which version of the text they saw. Changes: - journal.users gains a nullable `terms_version` text column (nullable so the three pre-existing users without a version are kept as-is). - New apps/journal/app/lib/legal.ts exports TERMS_VERSION as a single source of truth, reused by the legal pages' "Last updated" header and by the registration flow as the value to send/store. - Registration form posts `termsVersion` alongside `termsAccepted` on all three relevant steps (start, finish, register-magic-link). - API route validates that `termsVersion` is a non-empty string on any step that requires terms, and forwards it to the auth server. - auth.server finishRegistration and registerWithMagicLink now take `termsVersion` and persist it on the users row. - journal-auth spec gets a new scenario for version storage and a rejection scenario for missing version. PRIVACY_LAST_UPDATED is also exported from the same module and used by the Privacy page header, keeping both pages on a single legal.ts source of truth for "last updated" labels. Privacy is not per-user stored — it's informational, not contract. Existing users have NULL terms_version; if we ever prompt them to re-accept updated Terms, we can backfill with the version they re-accept at that point. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/journal/app/lib/auth.server.ts | 17 +++++++++++++++-- apps/journal/app/lib/legal.ts | 19 +++++++++++++++++++ apps/journal/app/routes/api.auth.register.ts | 12 ++++++++---- apps/journal/app/routes/auth.register.tsx | 18 ++++++++++++++++-- apps/journal/app/routes/legal.privacy.tsx | 3 ++- apps/journal/app/routes/legal.terms.tsx | 4 +++- openspec/specs/journal-auth/spec.md | 5 +++++ packages/db/src/schema/journal.ts | 1 + 8 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 apps/journal/app/lib/legal.ts diff --git a/apps/journal/app/lib/auth.server.ts b/apps/journal/app/lib/auth.server.ts index 204aa64..d91059a 100644 --- a/apps/journal/app/lib/auth.server.ts +++ b/apps/journal/app/lib/auth.server.ts @@ -53,6 +53,7 @@ export async function finishRegistration( username: string, response: RegistrationResponseJSON, challenge: string, + termsVersion: string, ) { const db = getDb(); @@ -77,6 +78,7 @@ export async function finishRegistration( username, domain, termsAcceptedAt: new Date(), + termsVersion, }); await db.insert(credentials).values({ @@ -147,7 +149,11 @@ export async function addPasskeyFinish( // --- Registration via Magic Link (no passkey) --- -export async function registerWithMagicLink(email: string, username: string): Promise { +export async function registerWithMagicLink( + email: string, + username: string, + termsVersion: string, +): Promise { const db = getDb(); const [existingEmail] = await db.select().from(users).where(eq(users.email, email)); @@ -159,7 +165,14 @@ export async function registerWithMagicLink(email: string, username: string): Pr const userId = randomUUID(); const domain = process.env.DOMAIN ?? "localhost"; - await db.insert(users).values({ id: userId, email, username, domain, termsAcceptedAt: new Date() }); + await db.insert(users).values({ + id: userId, + email, + username, + domain, + termsAcceptedAt: new Date(), + termsVersion, + }); // Create magic token for verification const token = randomBytes(32).toString("base64url"); diff --git a/apps/journal/app/lib/legal.ts b/apps/journal/app/lib/legal.ts new file mode 100644 index 0000000..a312bc7 --- /dev/null +++ b/apps/journal/app/lib/legal.ts @@ -0,0 +1,19 @@ +/** + * Version identifier for the currently-published Terms of Service. + * + * Stored on `users.terms_version` when a user accepts the Terms at + * registration. Bump this string whenever the Terms text changes in a way + * that warrants a re-acceptance — typically on each legal-review update. + * + * Kept as a plain date string (the "Last updated" date shown on the Terms + * page itself) so spec, storage, and UI stay in lockstep without a separate + * versioning scheme. + */ +export const TERMS_VERSION = "2026-04-19"; + +/** + * "Last updated" date shown on the Privacy Policy. Privacy changes don't + * require re-acceptance (the policy is informational, not contract), so this + * is display-only — not persisted. + */ +export const PRIVACY_LAST_UPDATED = "2026-04-19"; diff --git a/apps/journal/app/routes/api.auth.register.ts b/apps/journal/app/routes/api.auth.register.ts index eac3e68..2410a0b 100644 --- a/apps/journal/app/routes/api.auth.register.ts +++ b/apps/journal/app/routes/api.auth.register.ts @@ -6,14 +6,18 @@ import { logger } from "~/lib/logger.server"; export async function action({ request }: Route.ActionArgs) { const body = await request.json(); - const { step, email, username, response, challenge, userId, termsAccepted } = body; + const { step, email, username, response, challenge, userId, termsAccepted, termsVersion } = body; const origin = process.env.ORIGIN ?? `http://localhost:3000`; - // Registration steps require terms acceptance + // Registration steps require terms acceptance + the version the client + // agreed to (stored for audit so we can tell which text the user saw). const requiresTerms = step === "start" || step === "finish" || step === "register-magic-link"; if (requiresTerms && !termsAccepted) { return data({ error: "Terms of Service must be accepted" }, { status: 400 }); } + if (requiresTerms && (typeof termsVersion !== "string" || termsVersion.length === 0)) { + return data({ error: "Terms of Service version missing" }, { status: 400 }); + } try { if (step === "start") { @@ -22,7 +26,7 @@ export async function action({ request }: Route.ActionArgs) { } if (step === "finish") { - const newUserId = await finishRegistration(userId, email, username, response, challenge); + const newUserId = await finishRegistration(userId, email, username, response, challenge, termsVersion); const cookie = await createSession(newUserId, request); sendWelcome(email, username).catch((err) => logger.error({ err }, "Failed to send welcome email"), @@ -31,7 +35,7 @@ export async function action({ request }: Route.ActionArgs) { } if (step === "register-magic-link") { - const token = await registerWithMagicLink(email, username); + const token = await registerWithMagicLink(email, username, termsVersion); const link = `${origin}/auth/verify?token=${token}`; if (process.env.NODE_ENV !== "production") { return data({ step: "magic-link-sent", devLink: link }); diff --git a/apps/journal/app/routes/auth.register.tsx b/apps/journal/app/routes/auth.register.tsx index dec2692..a209eaa 100644 --- a/apps/journal/app/routes/auth.register.tsx +++ b/apps/journal/app/routes/auth.register.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; +import { TERMS_VERSION } from "~/lib/legal"; export default function RegisterPage() { const { t } = useTranslation("journal"); @@ -32,7 +33,13 @@ export default function RegisterPage() { const startResp = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ step: "start", email, username, termsAccepted }), + body: JSON.stringify({ + step: "start", + email, + username, + termsAccepted, + termsVersion: TERMS_VERSION, + }), }); const startData = await startResp.json(); @@ -53,6 +60,7 @@ export default function RegisterPage() { email, username, termsAccepted, + termsVersion: TERMS_VERSION, response: webAuthnResp, challenge: startData.options.challenge, userId: startData.userId, @@ -85,7 +93,13 @@ export default function RegisterPage() { const resp = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ step: "register-magic-link", email, username, termsAccepted }), + body: JSON.stringify({ + step: "register-magic-link", + email, + username, + termsAccepted, + termsVersion: TERMS_VERSION, + }), }); const result = await resp.json(); diff --git a/apps/journal/app/routes/legal.privacy.tsx b/apps/journal/app/routes/legal.privacy.tsx index 1d6a368..90a601c 100644 --- a/apps/journal/app/routes/legal.privacy.tsx +++ b/apps/journal/app/routes/legal.privacy.tsx @@ -1,4 +1,5 @@ import { operator } from "~/lib/operator"; +import { PRIVACY_LAST_UPDATED } from "~/lib/legal"; export function meta() { return [ @@ -14,7 +15,7 @@ export default function PrivacyPage() { Datenschutzerklärung / Privacy Policy

- Stand / Last updated: 2026-04-18. Die deutsche Fassung ist maßgeblich. + Stand / Last updated: {PRIVACY_LAST_UPDATED}. Die deutsche Fassung ist maßgeblich. The German version is authoritative; English summaries follow each section.

diff --git a/apps/journal/app/routes/legal.terms.tsx b/apps/journal/app/routes/legal.terms.tsx index ab18684..22015a2 100644 --- a/apps/journal/app/routes/legal.terms.tsx +++ b/apps/journal/app/routes/legal.terms.tsx @@ -1,3 +1,5 @@ +import { TERMS_VERSION } from "~/lib/legal"; + export function meta() { return [ { title: "Nutzungsbedingungen — trails.cool" }, @@ -12,7 +14,7 @@ export default function TermsPage() { Nutzungsbedingungen / Terms of Service

- Stand / Last updated: 2026-04-18 • Alpha — subject to change. Die + Stand / Last updated: {TERMS_VERSION} • Alpha — subject to change. Die deutsche Fassung ist maßgeblich. The German version is authoritative; English summaries follow each section.

diff --git a/openspec/specs/journal-auth/spec.md b/openspec/specs/journal-auth/spec.md index 2b2fb24..2949f10 100644 --- a/openspec/specs/journal-auth/spec.md +++ b/openspec/specs/journal-auth/spec.md @@ -27,3 +27,8 @@ The registration form SHALL require explicit acknowledgement of the Terms of Ser #### Scenario: Acknowledgement recorded - **WHEN** a user successfully registers - **THEN** the current timestamp is stored in `users.terms_accepted_at` +- **AND** the version identifier of the Terms the user saw is stored in `users.terms_version` + +#### Scenario: Missing version rejected +- **WHEN** a registration request arrives without a non-empty `termsVersion` field +- **THEN** the server responds with HTTP 400 and does not create a user diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts index 23365d3..97f717c 100644 --- a/packages/db/src/schema/journal.ts +++ b/packages/db/src/schema/journal.ts @@ -30,6 +30,7 @@ export const users = journalSchema.table("users", { bio: text("bio"), domain: text("domain").notNull(), termsAcceptedAt: timestamp("terms_accepted_at", { withTimezone: true }), + termsVersion: text("terms_version"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), });