Merge pull request #250 from trails-cool/feat/terms-version

Store Terms version alongside acceptance timestamp
This commit is contained in:
Ullrich Schäfer 2026-04-19 07:49:28 +02:00 committed by GitHub
commit c6c5ad3765
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 69 additions and 10 deletions

View file

@ -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<string> {
export async function registerWithMagicLink(
email: string,
username: string,
termsVersion: string,
): Promise<string> {
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");

View 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";

View file

@ -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 });

View file

@ -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();

View file

@ -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
</h1>
<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
section.
</p>

View file

@ -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
</h1>
<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;
English summaries follow each section.
</p>

View file

@ -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

View file

@ -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(),
});