fix(tos): trigger ToS re-accept on content hash, not lastmod date

The ToS modal re-prompted on every `lastmod` frontmatter bump even when the
terms body was unchanged (an unrelated PR bumped the date and forced a global
re-accept), and conversely could MISS a real body change if `lastmod` wasn't
bumped. Switch the trigger to a sha256 of the ToS body (frontmatter excluded),
so it fires iff the terms text actually changes.

- content.service: hash the gray-matter body in getStaticContent; replace the
  per-user checkTosUpdate resolver with static per-domain getTosMeta
  (hash + rollout baseline + settings field keys).
- Remove the content.checkTosUpdate tRPC route. The decision is now computed
  client-side in useToSUpdateModal from the SSR-seeded user.getSettings against
  the static tosMeta delivered via pageProps -- one fewer per-bootstrap query.
- Pure-hash check: (storedHash ?? baselineHash) !== currentHash. Users with no
  stored hash default to the hardcoded rollout baseline, so existing users are
  credited with the rollout text WITHOUT a data backfill and are only
  re-prompted once the body changes. First accept records a real per-domain hash.
- Persist the accepted hash on accept (TosModal) and at onboarding (TOS/RedTOS).
  Date fields are kept as a write-only acceptance-timestamp audit trail but no
  longer drive the trigger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Briant Diehl
2026-06-16 12:25:21 -06:00
parent 0de518ea00
commit 9f71df785f
10 changed files with 233 additions and 212 deletions
+11 -1
View File
@@ -27,11 +27,18 @@ export default function TosModal({
onAccepted,
slug,
fieldKey,
hashFieldKey,
contentHash,
showBackButton = true,
}: {
onAccepted: () => Promise<void>;
slug: string;
fieldKey: keyof SetUserSettingsInput;
// When provided (the main ToS-update flow), the accepted content hash is stored
// alongside the date so future re-prompts key on content, not the `lastmod` date.
// Other callers (e.g. Creator Program ToS) omit these and remain date-only.
hashFieldKey?: keyof SetUserSettingsInput;
contentHash?: string;
showBackButton?: boolean;
}) {
const dialog = useDialogContext();
@@ -70,7 +77,10 @@ export default function TosModal({
if (!acceptedCoC) return;
setLoading(true);
updateUserSettings.mutate({ [fieldKey]: new Date() });
updateUserSettings.mutate({
[fieldKey]: new Date(),
...(hashFieldKey && contentHash ? { [hashFieldKey]: contentHash } : {}),
});
handleClose();
await onAccepted();
+43 -44
View File
@@ -2,6 +2,7 @@ import dynamic from 'next/dynamic';
import { useEffect, useRef } from 'react';
import { dialogStore } from '~/components/Dialog/dialogStore';
import { useCurrentUser } from '~/hooks/useCurrentUser';
import { useAppContext } from '~/providers/AppProvider';
import { trpc } from '~/utils/trpc';
const TosModal = dynamic(() => import('~/components/ToSModal/TosModal'), {
@@ -10,55 +11,53 @@ const TosModal = dynamic(() => import('~/components/ToSModal/TosModal'), {
export function useToSUpdateModal() {
const currentUser = useCurrentUser();
const shownForVersion = useRef<Date | null>(null);
const queryUtils = trpc.useUtils();
// `content.checkTosUpdate` is SSR-seeded in AppProvider (an ancestor) as
// `initialData`. ToS lastmod only changes on a content deploy — never
// mid-session — so the per-load SSR snapshot is exactly as fresh as a live
// fetch. `staleTime: Infinity` keeps this observer from refetching the primed
// cache on mount, removing the per-bootstrap round-trip (the uncached
// server-side `readFile`). The accept flow's `setData` still patches
// `hasUpdate=false` regardless of staleTime.
const { data: tosUpdate } = trpc.content.checkTosUpdate.useQuery(undefined, {
// Static per-domain ToS metadata (current body hash + rollout baseline + field
// keys), delivered via SSR pageProps — no tRPC query. ToS content only changes
// on a deploy, so this is constant for the tab's lifetime; the modal can
// therefore only ever appear on a fresh load / new tab, never mid-session.
const { tosMeta } = useAppContext();
// The user's accepted-state side of the comparison. SSR-seeded in AppProvider,
// and patched by the accept flow's `setSettings` — so accepting closes the modal
// (hasUpdate recomputes to false) without any extra cache plumbing here.
const { data: settings } = trpc.user.getSettings.useQuery(undefined, {
enabled: !!currentUser,
staleTime: Infinity,
gcTime: Infinity,
});
// Dedup key: the content hash a modal was last shown for. Prevents re-triggering
// on re-render while the (forced, un-dismissable) modal is already open.
const shownForHash = useRef<string | null>(null);
useEffect(() => {
if (
currentUser &&
tosUpdate?.hasUpdate &&
tosUpdate.lastmod &&
(!shownForVersion.current ||
shownForVersion.current.getTime() !== tosUpdate.lastmod.getTime())
) {
shownForVersion.current = tosUpdate.lastmod;
if (!currentUser || !tosMeta || !settings) return;
dialogStore.trigger({
component: TosModal,
props: {
slug: 'tos',
fieldKey: tosUpdate.tosFieldKey || ('tosLastSeenDate' as const),
showBackButton: false,
onAccepted: async () => {
await currentUser.refresh();
// Use queryUtils to update the query data from trpc.content.checkTosUpdate
queryUtils.content.checkTosUpdate.setData(undefined, (old) =>
old
? {
...old,
hasUpdate: false,
lastmod: tosUpdate.lastmod,
}
: old
);
},
const { hash, baselineHash, fieldKey, hashFieldKey } = tosMeta;
const storedHashRaw = settings[hashFieldKey];
const storedHash = typeof storedHashRaw === 'string' ? storedHashRaw : undefined;
// Pure-hash trigger: re-prompt iff the current body hash differs from what the
// user accepted. Users with no stored hash default to the rollout baseline, so
// existing users are treated as having accepted the rollout text (no backfill,
// no mass re-prompt) and are only prompted once the body changes from it. Their
// first accept records a real hash. No date is consulted — a stray `lastmod`
// bump can't trigger, and a body change with no `lastmod` bump can't be missed.
const hasUpdate = (storedHash ?? baselineHash) !== hash;
if (!hasUpdate || shownForHash.current === hash) return;
shownForHash.current = hash;
dialogStore.trigger({
component: TosModal,
props: {
slug: 'tos',
fieldKey,
hashFieldKey,
contentHash: hash,
showBackButton: false,
onAccepted: async () => {
await currentUser.refresh();
},
});
}
}, [currentUser, tosUpdate]);
},
});
}, [currentUser, tosMeta, settings]);
return { tosUpdate };
return { tosMeta };
}
+14 -15
View File
@@ -56,7 +56,7 @@ import { IsClientProvider } from '~/providers/IsClientProvider';
import { ThemeProvider } from '~/providers/ThemeProvider';
import type { UserContentSettings } from '~/server/schema/user.schema';
import type { FeatureAccess } from '~/server/services/feature-flags.service';
import type { CheckTosUpdateResult } from '~/server/services/content.service';
import type { TosMeta } from '~/server/services/content.service';
import type { AnnouncementsSeed } from '~/providers/announcements-seed';
import type { BrowsingSettingsAddon } from '~/shared/constants/browsing-settings-addons';
import type { ParsedCookies } from '~/shared/utils/cookies';
@@ -102,7 +102,7 @@ type CustomAppProps = {
cookies: ParsedCookies;
flags: FeatureAccess;
userFeatureFlags?: FeatureAccess;
tosUpdate?: CheckTosUpdateResult;
tosMeta?: TosMeta;
announcements?: AnnouncementsSeed;
following?: number[];
seed: number;
@@ -127,7 +127,7 @@ function MyApp(props: CustomAppProps) {
cookies = parseCookies(getCookies()),
flags,
userFeatureFlags,
tosUpdate,
tosMeta,
announcements,
following,
seed = Date.now(),
@@ -184,7 +184,7 @@ function MyApp(props: CustomAppProps) {
seed={seed}
canIndex={canIndex}
settings={settings}
tosUpdate={tosUpdate}
tosMeta={tosMeta}
announcements={announcements}
following={following}
region={region}
@@ -376,7 +376,7 @@ MyApp.getInitialProps = async (appContext: AppContext) => {
// the documented failed-snapshot path without widening any downstream type.
type SettingsBootstrap = {
settings: UserContentSettings;
tosUpdate?: CheckTosUpdateResult;
tosMeta?: TosMeta;
announcements?: AnnouncementsSeed;
following?: number[];
session: Session | null;
@@ -428,13 +428,13 @@ MyApp.getInitialProps = async (appContext: AppContext) => {
);
settingsBootstrap = {
settings: undefined as unknown as UserContentSettings,
tosUpdate: undefined,
tosMeta: undefined,
announcements: undefined,
following: undefined,
session: null,
};
}
const { settings, session, tosUpdate, announcements, following } = settingsBootstrap;
const { settings, session, tosMeta, announcements, following } = settingsBootstrap;
// Pass these via the request so we can use them in SSR. Resolve the per-user
// feature flags and the global (redis-cached, identical-for-all-users) browsing
// setting addons in PARALLEL — neither depends on the other and both sit on
@@ -464,15 +464,14 @@ MyApp.getInitialProps = async (appContext: AppContext) => {
// (`user.getFeatureFlags`), a pure function of `settings.features` + the SSR
// host `flags`. Computed here via the SAME shared function the resolver uses
// (fs-free, safe in this client-bundled getInitialProps graph).
// - tosUpdate: the `content.checkTosUpdate` result — computed server-side in the
// - tosMeta: the static per-domain ToS metadata (lastmod + body hash + the
// per-domain settings field keys) — resolved server-side in the
// `/api/user/settings` route above and delivered via that fetch, so we never
// import `content.service` (and its `fs/promises` read) into this graph.
// ToS lastmod only changes on a content deploy (never mid-session), so a
// per-load SSR snapshot is exactly as fresh as the per-load client fetch.
// Only seed when the SSR `/api/user/settings` snapshot actually succeeded; on
// the rare failed-snapshot path (`settings` undefined) leave the seed undefined
// so the client query self-heals via a real fetch (staleTime: Infinity would
// never refetch a seed). The settings route applies the same gate to tosUpdate.
// ToS content only changes on a deploy (never mid-session). The show/hide
// decision is computed client-side in `useToSUpdateModal` against the seeded
// `user.getSettings`, so there is no tRPC query to seed here — `tosMeta` just
// rides down through pageProps to AppProvider (its `lastmod` is revived there).
let userFeatureFlags: FeatureAccess | undefined;
if (session?.user && settings) {
userFeatureFlags = computeUserFeatureFlagsOverlay(settings.features, flags);
@@ -505,7 +504,7 @@ MyApp.getInitialProps = async (appContext: AppContext) => {
browsingSettingsAddons,
flags,
userFeatureFlags,
tosUpdate,
tosMeta,
announcements,
following,
seed: Date.now(),
+15 -17
View File
@@ -1,7 +1,7 @@
import { getUserContentSettings } from '~/server/services/user.service';
import { PublicEndpoint } from '~/server/utils/endpoint-helpers';
import { getServerAuthSession } from '~/server/auth/get-server-auth-session';
import { checkTosUpdate } from '~/server/services/content.service';
import { getTosMeta } from '~/server/services/content.service';
import { getCurrentAnnouncements } from '~/server/services/announcement.service';
import { getUserFollows } from '~/server/redis/caches';
import { getRequestDomainColor } from '~/server/utils/server-domain';
@@ -13,23 +13,21 @@ export default PublicEndpoint(
// Use the content-settings view so SSR initialData matches the tRPC
// getSettings response shape (JSON settings + User-column toggles).
const settings = await getUserContentSettings(session?.user?.id ?? -1);
// tosUpdate + announcements + following all only need (session, settings) —
// compute them concurrently to keep this hot per-bootstrap route off the
// critical path. announcements + following swallow their own errors
// (`.catch`) so they can never reject the Promise.all and drop the critical
// settings/session payload; tosUpdate can still throw to the outer catch
// (preserving prior behaviour).
// tosMeta + announcements + following are computed concurrently to keep this
// hot per-bootstrap route off the critical path. announcements + following
// swallow their own errors (`.catch`) so they can never reject the Promise.all
// and drop the critical settings/session payload; tosMeta (static, no user
// input) can still throw to the outer catch (preserving prior behaviour).
const domainColor = getRequestDomainColor(req);
const [tosUpdate, announcements, following] = await Promise.all([
// Compute the `content.checkTosUpdate` result here (server-only API route)
// so `_app` getInitialProps can SSR-seed that query WITHOUT importing
const [tosMeta, announcements, following] = await Promise.all([
// Resolve the static per-domain ToS metadata here (server-only API route)
// so `_app` getInitialProps can deliver it WITHOUT importing
// `content.service` — which pulls `fs/promises` into `_app`'s client-bundled
// graph and breaks the build. Domain fallback matches createContext's
// `getRequestDomainColor(req) ?? 'blue'` so the seed stays byte-identical to
// a live `checkTosUpdate` fetch.
session?.user
? checkTosUpdate({ domainColor: domainColor ?? 'blue', userSettings: settings })
: Promise.resolve(undefined),
// graph and breaks the build. The show/hide decision is computed client-side
// against the seeded `user.getSettings`, so this is user-independent and we
// can resolve it for everyone (it's cheap + cached). Domain fallback matches
// createContext's `getRequestDomainColor(req) ?? 'blue'`.
getTosMeta({ domainColor: domainColor ?? 'blue' }),
// SSR-seed the ambient `announcement.getAnnouncements` query (fires on every
// bootstrap, anon + authed). Computed here — NOT in `_app` getInitialProps —
// because `announcement.service` is server-only and importing it into `_app`
@@ -52,7 +50,7 @@ export default PublicEndpoint(
]);
res.status(200).json({
settings,
tosUpdate,
tosMeta,
announcements,
following,
session: session?.user && Object.keys(session.user).length > 0 ? session : null,
+11 -46
View File
@@ -1,6 +1,6 @@
import React, { createContext, useContext, useMemo, useState } from 'react';
import React, { createContext, useContext, useState } from 'react';
import type { UserContentSettings } from '~/server/schema/user.schema';
import type { CheckTosUpdateResult } from '~/server/services/content.service';
import type { TosMeta } from '~/server/services/content.service';
import type { RegionInfo } from '~/server/utils/region-blocking';
import type { VerifiedBot } from '~/server/utils/bot-detection/verify-bot';
import type { ColorDomain, ServerDomains } from '~/shared/constants/domain.constants';
@@ -12,9 +12,10 @@ import { reviveAnnouncementsSeed } from '~/providers/announcements-seed';
type AppProviderProps = {
children: React.ReactNode;
settings: UserContentSettings;
// SSR-computed `content.checkTosUpdate` result (logged-in only). Seeds the
// query so `useToSUpdateModal` never fires it on bootstrap.
tosUpdate?: CheckTosUpdateResult;
// Static per-domain ToS metadata (lastmod + body hash + settings field keys).
// Exposed via context; `useToSUpdateModal` compares it against the seeded
// `user.getSettings` to decide whether to show the ToS modal.
tosMeta?: TosMeta;
// SSR-computed `announcement.getAnnouncements` result (anon + authed). Carried
// down to `useGetAnnouncements`, which seeds the query under the client's
// `useDomainColor()` key — this provider sits above FeatureFlagsProvider so it
@@ -51,6 +52,7 @@ type AppContext = {
availableOAuthProviders: string[];
verifiedBot: VerifiedBot | null;
announcements?: AnnouncementsSeed;
tosMeta?: TosMeta;
};
const Context = createContext<AppContext | null>(null);
export function useAppContext() {
@@ -71,32 +73,10 @@ export function useServerDomains(): Record<ColorDomain, string> {
red: serverDomains.red?.primary ?? 'civitai.red',
};
}
// Next pageProps stringify Dates; a live superjson tRPC response keeps them as
// Date objects. Re-hydrate the Date-typed fields of the checkTosUpdate snapshot
// so the SSR seed matches a live fetch (the modal hook calls `.getTime()`).
const toDate = (v: unknown): Date | undefined =>
v == null ? undefined : v instanceof Date ? v : new Date(v as string | number);
function reviveTosUpdate(tosUpdate?: CheckTosUpdateResult): CheckTosUpdateResult | undefined {
if (!tosUpdate) return undefined;
// `hasUpdate` is `true` (boolean) on the never-seen path, otherwise the
// `lastmod` Date when an update exists. Preserve booleans, revive date strings.
const hasUpdate =
typeof tosUpdate.hasUpdate === 'boolean' || tosUpdate.hasUpdate == null
? tosUpdate.hasUpdate
: toDate(tosUpdate.hasUpdate);
return {
...tosUpdate,
hasUpdate,
lastmod: toDate(tosUpdate.lastmod),
userLastSeen: toDate(tosUpdate.userLastSeen),
};
}
export function AppProvider({
children,
settings,
tosUpdate,
tosMeta,
announcements,
following,
domain,
@@ -111,24 +91,6 @@ export function AppProvider({
// for a logged-out user is a guaranteed 401. `initialData` still seeds the cache
// for everyone; only the network fetch is suppressed when not logged in.
trpc.user.getSettings.useQuery(undefined, { initialData: settings, enabled: isAuthed });
// Seed `content.checkTosUpdate` from the SSR snapshot so `useToSUpdateModal`
// (mounted deeper, in AppLayout) reads a primed cache and never fires the
// per-bootstrap fetch. ToS lastmod only changes on a content deploy, so this
// snapshot is exactly as fresh as a live fetch. `staleTime: Infinity` keeps
// the seeded observer from refetching; the accept flow's `setData` still
// patches `hasUpdate=false` regardless of staleTime.
//
// The SSR value travels via Next pageProps (plain JSON), which stringifies
// Dates — but a live tRPC fetch returns real Date objects (superjson) and the
// modal hook calls `.getTime()` on `lastmod`. Revive the Date fields so the
// seed is shape-identical to a live response.
const tosUpdateInitial = useMemo(() => reviveTosUpdate(tosUpdate), [tosUpdate]);
trpc.content.checkTosUpdate.useQuery(undefined, {
initialData: tosUpdateInitial,
enabled: !!tosUpdateInitial,
staleTime: Infinity,
gcTime: Infinity,
});
// Seed `user.getFollowingUsers` (the followed-userId list) from the SSR
// snapshot so the ambient follow/notify buttons read a primed cache and never
// fire it on bootstrap. The list only changes via the user's own follow/
@@ -157,6 +119,9 @@ export function AppProvider({
availableOAuthProviders,
verifiedBot,
announcements: reviveAnnouncementsSeed(announcements),
// All-string payload (current hash + baseline + field keys) — survives the
// pageProps JSON round-trip as-is, no revival needed.
tosMeta,
}));
return <Context.Provider value={state}>{children}</Context.Provider>;
+11 -3
View File
@@ -12,6 +12,7 @@ import {
SearchIndexUpdateQueueAction,
} from '~/server/common/enums';
import type { Context, ProtectedContext } from '~/server/createContext';
import { getStaticContent } from '~/server/services/content.service';
import { dbRead, dbWrite } from '~/server/db/client';
import { onboardingCompletedCounter, onboardingErrorCounter } from '~/server/prom/client';
import { getUserFollows } from '~/server/redis/caches';
@@ -327,17 +328,24 @@ export const completeOnboardingHandler = async ({
const changed = onboarding !== ctx.user.onboarding;
switch (input.step) {
case OnboardingSteps.TOS:
case OnboardingSteps.TOS: {
const now = new Date();
// Store the accepted content hash alongside the date so a freshly-onboarded
// user is hash-backed immediately and immune to stray `lastmod` bumps.
const tos = await getStaticContent({ slug: ['tos'], ctx: { domain } as Context });
await dbWrite.user.update({ where: { id }, data: { onboarding } });
await setUserSetting(
id,
domain === 'green' ? { tosGreenLastSeenDate: now } : { tosLastSeenDate: now }
domain === 'green'
? { tosGreenLastSeenDate: now, tosGreenAcceptedHash: tos.hash }
: { tosLastSeenDate: now, tosAcceptedHash: tos.hash }
);
break;
}
case OnboardingSteps.RedTOS: {
const tos = await getStaticContent({ slug: ['tos'], ctx: { domain } as Context });
await dbWrite.user.update({ where: { id }, data: { onboarding } });
await setUserSetting(id, { tosRedLastSeenDate: new Date() });
await setUserSetting(id, { tosRedLastSeenDate: new Date(), tosRedAcceptedHash: tos.hash });
break;
}
case OnboardingSteps.Profile: {
+2 -14
View File
@@ -1,13 +1,8 @@
import * as z from 'zod';
import { CacheTTL } from '~/server/common/constants';
import { cacheIt } from '~/server/middleware.trpc';
import {
checkTosUpdate,
getMarkdownContent,
getStaticContent,
} from '~/server/services/content.service';
import { getUserSettings } from '~/server/services/user.service';
import { protectedProcedure, publicProcedure, router } from '~/server/trpc';
import { getMarkdownContent, getStaticContent } from '~/server/services/content.service';
import { publicProcedure, router } from '~/server/trpc';
import { TokenScope } from '~/shared/constants/token-scope.constants';
const slugSchema = z.object({
@@ -30,11 +25,4 @@ export const contentRouter = router({
.meta({ requiredScope: TokenScope.MediaRead })
.input(z.object({ key: z.string() }))
.query(({ input }) => getMarkdownContent(input)),
checkTosUpdate: protectedProcedure
.meta({ requiredScope: TokenScope.MediaRead })
.query(async ({ ctx }) => {
const userSettings = ctx.user ? await getUserSettings(ctx.user.id) : {};
// Shared computation — also used by the SSR seed in _app getInitialProps.
return checkTosUpdate({ domainColor: ctx.domain, userSettings });
}),
});
+8
View File
@@ -281,6 +281,11 @@ export const userSettingsSchema = z.object({
tosLastSeenDate: z.date().optional(),
tosGreenLastSeenDate: z.date().optional(),
tosRedLastSeenDate: z.date().optional(),
// sha256 of the ToS body the user last accepted, per domain. Compared against
// the current body hash to decide whether to re-prompt (see useToSUpdateModal).
tosAcceptedHash: z.string().optional(),
tosGreenAcceptedHash: z.string().optional(),
tosRedAcceptedHash: z.string().optional(),
preferredFiatCurrency: z.string().optional(),
});
@@ -304,6 +309,9 @@ export const setUserSettingsInput = z.object({
tosLastSeenDate: z.date().optional(),
tosGreenLastSeenDate: z.date().optional(),
tosRedLastSeenDate: z.date().optional(),
tosAcceptedHash: z.string().optional(),
tosGreenAcceptedHash: z.string().optional(),
tosRedAcceptedHash: z.string().optional(),
preferredFiatCurrency: z.string().optional(),
});
+68 -39
View File
@@ -1,3 +1,4 @@
import { createHash } from 'crypto';
import { readFile } from 'fs/promises';
import { access } from 'fs/promises';
import matter from 'gray-matter';
@@ -13,12 +14,24 @@ type StaticContentResult = {
description: string;
lastmod: Date | undefined;
content: string;
// sha256 of the body ONLY (frontmatter excluded). This is the trigger signal
// for the ToS-update modal: it changes iff the terms text changes, so a stray
// `lastmod` bump (or any frontmatter edit) can't force a global re-accept.
hash: string;
};
// Hash the rendered body, not the raw file or re-serialized doc — gray-matter
// has already stripped the frontmatter, so `lastmod`/`title` never feed the hash.
// Normalize line endings + trailing whitespace so cosmetic/EOL churn is inert.
function hashContent(content: string) {
const normalized = content.replace(/\r\n/g, '\n').trimEnd();
return createHash('sha256').update(normalized).digest('hex');
}
// Static-content files are bundled into the image and read-only at runtime — they
// change only on deploy (new deploy = new process = fresh cache). `getStaticContent`
// is now on the SSR critical path (ToS checks run in `_app` getInitialProps on every
// logged-in full render via `checkTosUpdate`), so an uncached `readFile` + gray-matter
// is now on the SSR critical path (ToS metadata is resolved on every full render
// via `getTosMeta`), so an uncached `readFile` + gray-matter
// parse per call is wasteful. Cache parsed results in-memory with a short TTL; keyed
// by slug + domain so domain-specific variants (e.g. tos.green.md) don't collide. The
// key set is bounded by the (small, fixed) number of static-content files × domains.
@@ -67,6 +80,7 @@ export async function getStaticContent({ slug, ctx }: { slug: string[]; ctx?: Co
description: frontmatter.description as string,
lastmod: frontmatter.lastmod ? new Date(frontmatter.lastmod) : undefined,
content,
hash: hashContent(content),
};
staticContentCache.set(cacheKey, { value, expires: Date.now() + STATIC_CONTENT_TTL_MS });
return value;
@@ -80,60 +94,75 @@ export async function getStaticContent({ slug, ctx }: { slug: string[]; ctx?: Co
}
// Map domain colors to ToS field names. Single source of truth shared by the
// `content.checkTosUpdate` resolver and the SSR seed in _app getInitialProps.
// `getTosMeta` resolver and the client-side comparison in `useToSUpdateModal`.
// The date fields are written on accept for audit ("when did they last accept")
// but no longer drive the show/hide decision — that's purely hash-based now.
export const tosFieldMap = {
green: 'tosGreenLastSeenDate',
red: 'tosRedLastSeenDate',
blue: 'tosLastSeenDate', // default
} as const;
export type CheckTosUpdateResult = {
// Matches the original resolver expression exactly: `!userTosLastSeen` is a
// boolean, the right branch is `Date | undefined` (truthy when an update
// exists). Consumers only read it for truthiness, so the union is preserved
// verbatim to keep the SSR seed byte-identical to a live fetch.
hasUpdate: boolean | Date | undefined;
lastmod: Date | undefined;
userLastSeen: Date | undefined;
domainColor: string | undefined;
tosFieldKey: (typeof tosFieldMap)[keyof typeof tosFieldMap];
// Parallel map of the per-domain settings field that stores the content hash the
// user last accepted. This is the ONLY trigger signal: re-prompt iff the current
// body hash differs from the user's stored (or default-baseline) hash.
export const tosHashFieldMap = {
green: 'tosGreenAcceptedHash',
red: 'tosRedAcceptedHash',
blue: 'tosAcceptedHash', // default
} as const;
// Body hashes of the ToS as of the hash-mechanism rollout. Used as the DEFAULT
// "accepted hash" for any user who has none recorded — so existing users are
// credited with having accepted the rollout text WITHOUT a data backfill, and are
// re-prompted only once the body changes away from this baseline. (red has no
// tos.red.md, so it falls back to tos.md → same hash as blue.)
//
// IMPORTANT: these must equal `hashContent(<deployed body>)` for each domain at
// rollout. Regenerate (gray-matter body → CRLF→LF → trimEnd → sha256) ONLY if you
// deliberately intend a body change to re-prompt the not-yet-hash-backed users.
export const tosBaselineHashMap = {
green: '8dd1cc867cdd5f320ca139243c4533871f0e5a1dbd8c23df3770f77020a6b293',
red: '33d6e2d123ef60d6f7a3eb1b0988879a957b13a6b6fffe63bd35f2a7f0b4748a',
blue: '33d6e2d123ef60d6f7a3eb1b0988879a957b13a6b6fffe63bd35f2a7f0b4748a', // default
} as const;
// The static, per-domain ToS metadata the client needs to decide whether to show
// the ToS modal. It depends ONLY on the deployed content + frozen baseline, never
// on the user — the per-user comparison happens client-side against the already-
// seeded `user.getSettings`. Every field is a plain string, so the object survives
// the pageProps JSON round-trip with no revival needed.
export type TosMeta = {
hash: string;
// The default accepted-hash for users with none stored (see tosBaselineHashMap).
baselineHash: string;
// The per-domain settings fields the client compares against (hash) and writes
// on accept (both). Resolved here so the client never imports the domain maps
// (and thus never pulls this fs-touching module into its bundle).
fieldKey: (typeof tosFieldMap)[keyof typeof tosFieldMap];
hashFieldKey: (typeof tosHashFieldMap)[keyof typeof tosHashFieldMap];
};
/**
* Pure-ish computation of the `content.checkTosUpdate` result. Single source of
* truth shared by the tRPC resolver and the SSR seed in _app getInitialProps so
* the injected initialData byte-matches a live fetch.
* Resolve the static ToS metadata for a domain. Cheap and cached (rides the
* in-memory `getStaticContent` cache), and unlike the old `checkTosUpdate`
* takes no user settings: the modal's show/hide decision is computed client-side
* from the seeded `user.getSettings` against this metadata.
*
* @param domainColor the request's resolved domain color (green/red/blue)
* @param userSettings the user's JSON settings (reads `tos*LastSeenDate`)
*/
// Only the three ToS-last-seen fields are read here. Typed structurally so both
// `UserSettingsSchema` (resolver) and `UserContentSettings` (SSR seed) — and the
// `{}` no-settings case — are assignable without an index-signature cast.
type TosUserSettings = Partial<
Record<(typeof tosFieldMap)[keyof typeof tosFieldMap], Date | string | null>
>;
export async function checkTosUpdate({
export async function getTosMeta({
domainColor,
userSettings,
}: {
domainColor: string | undefined;
userSettings: TosUserSettings;
}): Promise<CheckTosUpdateResult> {
}): Promise<TosMeta> {
const tos = await getStaticContent({ slug: ['tos'], ctx: { domain: domainColor } as Context });
const tosFieldKey = tosFieldMap[domainColor as keyof typeof tosFieldMap] || 'tosLastSeenDate';
const userTosLastSeenRaw = userSettings[tosFieldKey] as Date | string | undefined;
const userTosLastSeen = userTosLastSeenRaw ? new Date(userTosLastSeenRaw) : undefined;
const tosLastMod = tos.lastmod ? new Date(tos.lastmod) : undefined;
return {
hasUpdate: !userTosLastSeen || (tosLastMod && tosLastMod > userTosLastSeen),
lastmod: tosLastMod,
userLastSeen: userTosLastSeen,
domainColor,
tosFieldKey,
hash: tos.hash,
baselineHash:
tosBaselineHashMap[domainColor as keyof typeof tosBaselineHashMap] || tosBaselineHashMap.blue,
fieldKey: tosFieldMap[domainColor as keyof typeof tosFieldMap] || 'tosLastSeenDate',
hashFieldKey: tosHashFieldMap[domainColor as keyof typeof tosHashFieldMap] || 'tosAcceptedHash',
};
}
+50 -33
View File
@@ -20,9 +20,10 @@ import { storageStatePath } from './preview-fixtures';
* 1. `user.getFeatureFlags` is SSR-seeded: its value is present in
* `__NEXT_DATA__.props.pageProps.userFeatureFlags`, AND the client never
* fires `user.getFeatureFlags` on bootstrap.
* 2. `content.checkTosUpdate` is SSR-seeded: its value is present in
* `__NEXT_DATA__.props.pageProps.tosUpdate`, AND the client never fires
* `content.checkTosUpdate` on bootstrap.
* 2. The static ToS metadata is SSR-delivered: it's present in
* `__NEXT_DATA__.props.pageProps.tosMeta`, AND the client never fires a
* `content.checkTosUpdate` request (that route no longer exists the modal
* decision is computed client-side from `tosMeta` + the seeded getSettings).
*
* The tRPC client is non-batched (src/utils/trpc.ts httpLink), so the procedure
* name is in the request path a substring match on the URL is a reliable
@@ -103,8 +104,8 @@ async function fetchTrpcQueryJson(
const out = await page.evaluate(
async ({ proc, inp }) => {
// tRPC's non-batched httpLink puts a query's input in the `?input=` query
// string as the superjson envelope `{"json": <value>}`. Procedures with no
// input (getFeatureFlags/checkTosUpdate) omit it entirely.
// string as the superjson envelope `{"json": <value>}`. No-input procedures
// (e.g. getFeatureFlags) omit it entirely.
const qs =
typeof inp === 'undefined'
? ''
@@ -152,7 +153,7 @@ function assertAnnouncementsSeedEqualsLive(seed: unknown, live: unknown, label:
}
}
test.describe('SSR-injected getFeatureFlags + checkTosUpdate (logged-in)', () => {
test.describe('SSR-injected getFeatureFlags + ToS metadata (logged-in)', () => {
test.use({ storageState: storageStatePath('mod') });
test('both procedures are seeded into __NEXT_DATA__ and not fetched on bootstrap', async ({
@@ -170,14 +171,18 @@ test.describe('SSR-injected getFeatureFlags + checkTosUpdate (logged-in)', () =>
'userFeatureFlags is an object'
).toBe(true);
// (b) SSR payload carries the checkTosUpdate snapshot (an object with the
// resolver's return shape). hasUpdate may be true/false/a date — we only
// assert the object is present.
const tosUpdate = await readPageProp(page, 'tosUpdate');
expect(tosUpdate.present, 'tosUpdate present in __NEXT_DATA__ pageProps').toBe(true);
// (b) SSR payload carries the static ToS metadata (lastmod + body hash +
// per-domain field keys). The show/hide decision is computed client-side,
// so we just assert the metadata object is present and carries a hash.
const tosMeta = await readPageProp(page, 'tosMeta');
expect(tosMeta.present, 'tosMeta present in __NEXT_DATA__ pageProps').toBe(true);
expect(
typeof tosUpdate.value === 'object' && tosUpdate.value !== null,
'tosUpdate is an object'
typeof tosMeta.value === 'object' && tosMeta.value !== null,
'tosMeta is an object'
).toBe(true);
expect(
typeof (tosMeta.value as { hash?: unknown }).hash === 'string',
'tosMeta carries a content hash'
).toBe(true);
// (c) The client never issues either now-SSR-injected procedure on bootstrap.
@@ -198,30 +203,29 @@ test.describe('SSR-injected getFeatureFlags + checkTosUpdate (logged-in)', () =>
).toHaveLength(0);
});
// The core correctness property: the SSR-injected seed must be byte-identical
// to what a live resolver fetch returns. With `staleTime: Infinity` a wrong
// seed would never self-correct (until reload), so a divergence here means
// users are served wrong feature flags / ToS state. Both paths call the same
// shared fn (computeUserFeatureFlagsOverlay / checkTosUpdate) so this should
// hold by construction — this asserts it for a real authed user end-to-end.
test('SSR seed byte-equals a live fetch for both procedures', async ({ page }) => {
// The core correctness property for getFeatureFlags: the SSR-injected seed must
// be byte-identical to a live resolver fetch (with `staleTime: Infinity` a wrong
// seed would never self-correct until reload). ToS metadata has NO resolver
// anymore — it's static deploy data delivered via pageProps — so we assert its
// static shape instead of a live comparison.
test('feature-flag SSR seed byte-equals a live fetch; tosMeta carries static fields', async ({
page,
}) => {
await page.goto(AUTHED_LANDING, { waitUntil: 'domcontentloaded' });
const seedFlags = await readPageProp(page, 'userFeatureFlags');
const seedTos = await readPageProp(page, 'tosUpdate');
const seedTos = await readPageProp(page, 'tosMeta');
expect(seedFlags.present, 'userFeatureFlags seed present in __NEXT_DATA__').toBe(true);
expect(seedTos.present, 'tosUpdate seed present in __NEXT_DATA__').toBe(true);
expect(seedTos.present, 'tosMeta seed present in __NEXT_DATA__').toBe(true);
const liveFlags = await fetchTrpcQueryJson(page, FEATURE_FLAGS_PROCEDURE);
const liveTos = await fetchTrpcQueryJson(page, TOS_PROCEDURE);
// Normalize the wire-representation difference for "no value" fields: the SSR
// seed crosses the wire via JSON.stringify (drops `undefined` keys) while the
// live fetch uses superjson (encodes `undefined` as `null`). Both mean absent;
// stripping null/undefined-valued keys compares the meaningful fields
// apples-to-apples. `false`/0/'' are kept, so a real value-vs-absent
// divergence is still caught. (e.g. checkTosUpdate's unused `userLastSeen`:
// omitted in the seed, `null` in the live fetch — semantically identical.)
// divergence is still caught.
const stripNullish = (o: unknown) =>
o && typeof o === 'object'
? Object.fromEntries(
@@ -233,10 +237,20 @@ test.describe('SSR-injected getFeatureFlags + checkTosUpdate (logged-in)', () =>
stripNullish(seedFlags.value),
'user.getFeatureFlags SSR seed must byte-equal a live resolver fetch'
).toEqual(stripNullish(liveFlags));
expect(
stripNullish(seedTos.value),
'content.checkTosUpdate SSR seed must byte-equal a live resolver fetch'
).toEqual(stripNullish(liveTos));
// tosMeta is static deploy data. Assert the fields the client relies on are
// present: current body hash + rollout baseline + the per-domain settings field
// keys it compares/writes.
const tos = seedTos.value as {
hash?: unknown;
baselineHash?: unknown;
fieldKey?: unknown;
hashFieldKey?: unknown;
};
expect(typeof tos.hash, 'tosMeta.hash is a string').toBe('string');
expect(typeof tos.baselineHash, 'tosMeta.baselineHash is a string').toBe('string');
expect(typeof tos.fieldKey, 'tosMeta.fieldKey is a string').toBe('string');
expect(typeof tos.hashFieldKey, 'tosMeta.hashFieldKey is a string').toBe('string');
});
});
@@ -404,11 +418,14 @@ test.describe('SSR-injected getFollowingUsers (logged-in)', () => {
* live (the toggle mutation's optimistic setData + invalidate refetch the
* seeded query invalidate refetches regardless of staleTime).
* [ ] Opt-2 ToS-never-accepted: a user who has NEVER accepted the ToS still
* gets the ToS modal on first load (the `hasUpdate=true` when last-seen is
* undefined path), seeded from SSR rather than a client fetch.
* gets the ToS modal on first load (no stored hash/date `hasUpdate` true),
* computed client-side from `tosMeta` + the SSR-seeded getSettings.
* [ ] Opt-2 ToS accept persists: accepting the ToS dismisses the modal (the
* accept flow's setData patches `hasUpdate=false`) and it stays dismissed
* on reload; the domainfield mapping is correct on green/red/blue hosts.
* accept mutation patches the getSettings cache with the new hash, so the
* hook recomputes `hasUpdate=false`) and it stays dismissed on reload; the
* domainfield mapping is correct on green/red/blue hosts.
* [ ] Opt-2 ToS no false-positive: a stray `lastmod` bump with NO body change
* must NOT re-prompt a hash-backed user (the body hash is unchanged).
* [ ] Announcements render + dismiss: with an ACTIVE announcement, the banner
* renders from the SSR seed (now in the initial HTML) and dismissing it
* persists across navigation (the dismissed-ids zustand store is unchanged