Store Terms version alongside acceptance timestamp
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) <noreply@anthropic.com>
This commit is contained in:
parent
c532a1024b
commit
9c4c3d6444
8 changed files with 69 additions and 10 deletions
|
|
@ -53,6 +53,7 @@ export async function finishRegistration(
|
||||||
username: string,
|
username: string,
|
||||||
response: RegistrationResponseJSON,
|
response: RegistrationResponseJSON,
|
||||||
challenge: string,
|
challenge: string,
|
||||||
|
termsVersion: string,
|
||||||
) {
|
) {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
|
|
@ -77,6 +78,7 @@ export async function finishRegistration(
|
||||||
username,
|
username,
|
||||||
domain,
|
domain,
|
||||||
termsAcceptedAt: new Date(),
|
termsAcceptedAt: new Date(),
|
||||||
|
termsVersion,
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.insert(credentials).values({
|
await db.insert(credentials).values({
|
||||||
|
|
@ -147,7 +149,11 @@ export async function addPasskeyFinish(
|
||||||
|
|
||||||
// --- Registration via Magic Link (no passkey) ---
|
// --- Registration via Magic Link (no passkey) ---
|
||||||
|
|
||||||
export async function registerWithMagicLink(email: string, username: string): Promise<string> {
|
export async function registerWithMagicLink(
|
||||||
|
email: string,
|
||||||
|
username: string,
|
||||||
|
termsVersion: string,
|
||||||
|
): Promise<string> {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
const [existingEmail] = await db.select().from(users).where(eq(users.email, email));
|
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 userId = randomUUID();
|
||||||
const domain = process.env.DOMAIN ?? "localhost";
|
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
|
// Create magic token for verification
|
||||||
const token = randomBytes(32).toString("base64url");
|
const token = randomBytes(32).toString("base64url");
|
||||||
|
|
|
||||||
19
apps/journal/app/lib/legal.ts
Normal file
19
apps/journal/app/lib/legal.ts
Normal file
|
|
@ -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";
|
||||||
|
|
@ -6,14 +6,18 @@ import { logger } from "~/lib/logger.server";
|
||||||
|
|
||||||
export async function action({ request }: Route.ActionArgs) {
|
export async function action({ request }: Route.ActionArgs) {
|
||||||
const body = await request.json();
|
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`;
|
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";
|
const requiresTerms = step === "start" || step === "finish" || step === "register-magic-link";
|
||||||
if (requiresTerms && !termsAccepted) {
|
if (requiresTerms && !termsAccepted) {
|
||||||
return data({ error: "Terms of Service must be accepted" }, { status: 400 });
|
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 {
|
try {
|
||||||
if (step === "start") {
|
if (step === "start") {
|
||||||
|
|
@ -22,7 +26,7 @@ export async function action({ request }: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step === "finish") {
|
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);
|
const cookie = await createSession(newUserId, request);
|
||||||
sendWelcome(email, username).catch((err) =>
|
sendWelcome(email, username).catch((err) =>
|
||||||
logger.error({ err }, "Failed to send welcome email"),
|
logger.error({ err }, "Failed to send welcome email"),
|
||||||
|
|
@ -31,7 +35,7 @@ export async function action({ request }: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step === "register-magic-link") {
|
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}`;
|
const link = `${origin}/auth/verify?token=${token}`;
|
||||||
if (process.env.NODE_ENV !== "production") {
|
if (process.env.NODE_ENV !== "production") {
|
||||||
return data({ step: "magic-link-sent", devLink: link });
|
return data({ step: "magic-link-sent", devLink: link });
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TERMS_VERSION } from "~/lib/legal";
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const { t } = useTranslation("journal");
|
const { t } = useTranslation("journal");
|
||||||
|
|
@ -32,7 +33,13 @@ export default function RegisterPage() {
|
||||||
const startResp = await fetch("/api/auth/register", {
|
const startResp = await fetch("/api/auth/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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();
|
const startData = await startResp.json();
|
||||||
|
|
||||||
|
|
@ -53,6 +60,7 @@ export default function RegisterPage() {
|
||||||
email,
|
email,
|
||||||
username,
|
username,
|
||||||
termsAccepted,
|
termsAccepted,
|
||||||
|
termsVersion: TERMS_VERSION,
|
||||||
response: webAuthnResp,
|
response: webAuthnResp,
|
||||||
challenge: startData.options.challenge,
|
challenge: startData.options.challenge,
|
||||||
userId: startData.userId,
|
userId: startData.userId,
|
||||||
|
|
@ -85,7 +93,13 @@ export default function RegisterPage() {
|
||||||
const resp = await fetch("/api/auth/register", {
|
const resp = await fetch("/api/auth/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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();
|
const result = await resp.json();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { operator } from "~/lib/operator";
|
import { operator } from "~/lib/operator";
|
||||||
|
import { PRIVACY_LAST_UPDATED } from "~/lib/legal";
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return [
|
return [
|
||||||
|
|
@ -14,7 +15,7 @@ export default function PrivacyPage() {
|
||||||
Datenschutzerklärung / Privacy Policy
|
Datenschutzerklärung / Privacy Policy
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
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
|
The German version is authoritative; English summaries follow each
|
||||||
section.
|
section.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { TERMS_VERSION } from "~/lib/legal";
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return [
|
return [
|
||||||
{ title: "Nutzungsbedingungen — trails.cool" },
|
{ title: "Nutzungsbedingungen — trails.cool" },
|
||||||
|
|
@ -12,7 +14,7 @@ export default function TermsPage() {
|
||||||
Nutzungsbedingungen / Terms of Service
|
Nutzungsbedingungen / Terms of Service
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
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;
|
deutsche Fassung ist maßgeblich. The German version is authoritative;
|
||||||
English summaries follow each section.
|
English summaries follow each section.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -27,3 +27,8 @@ The registration form SHALL require explicit acknowledgement of the Terms of Ser
|
||||||
#### Scenario: Acknowledgement recorded
|
#### Scenario: Acknowledgement recorded
|
||||||
- **WHEN** a user successfully registers
|
- **WHEN** a user successfully registers
|
||||||
- **THEN** the current timestamp is stored in `users.terms_accepted_at`
|
- **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
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export const users = journalSchema.table("users", {
|
||||||
bio: text("bio"),
|
bio: text("bio"),
|
||||||
domain: text("domain").notNull(),
|
domain: text("domain").notNull(),
|
||||||
termsAcceptedAt: timestamp("terms_accepted_at", { withTimezone: true }),
|
termsAcceptedAt: timestamp("terms_accepted_at", { withTimezone: true }),
|
||||||
|
termsVersion: text("terms_version"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue