diff --git a/src/components/ToSModal/TosModal.tsx b/src/components/ToSModal/TosModal.tsx index 22313fd1a2..d20906b2ac 100644 --- a/src/components/ToSModal/TosModal.tsx +++ b/src/components/ToSModal/TosModal.tsx @@ -27,11 +27,18 @@ export default function TosModal({ onAccepted, slug, fieldKey, + hashFieldKey, + contentHash, showBackButton = true, }: { onAccepted: () => Promise; 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(); diff --git a/src/hooks/useToSUpdateModal.ts b/src/hooks/useToSUpdateModal.ts index e12d8f9518..8c9512fbc7 100644 --- a/src/hooks/useToSUpdateModal.ts +++ b/src/hooks/useToSUpdateModal.ts @@ -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(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(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 }; } diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 994b5bfda1..b7f923268d 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -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(), diff --git a/src/pages/api/user/settings.ts b/src/pages/api/user/settings.ts index e6592e1e5a..a67a01cddf 100644 --- a/src/pages/api/user/settings.ts +++ b/src/pages/api/user/settings.ts @@ -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, diff --git a/src/providers/AppProvider.tsx b/src/providers/AppProvider.tsx index 356df8a5fb..66b3265929 100644 --- a/src/providers/AppProvider.tsx +++ b/src/providers/AppProvider.tsx @@ -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(null); export function useAppContext() { @@ -71,32 +73,10 @@ export function useServerDomains(): Record { 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 {children}; diff --git a/src/server/controllers/user.controller.ts b/src/server/controllers/user.controller.ts index 23d202a250..3e1076ff85 100644 --- a/src/server/controllers/user.controller.ts +++ b/src/server/controllers/user.controller.ts @@ -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: { diff --git a/src/server/routers/content.router.ts b/src/server/routers/content.router.ts index 2074ab3c27..a04c46695a 100644 --- a/src/server/routers/content.router.ts +++ b/src/server/routers/content.router.ts @@ -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 }); - }), }); diff --git a/src/server/schema/user.schema.ts b/src/server/schema/user.schema.ts index 15a96fb9a4..597714c8ca 100644 --- a/src/server/schema/user.schema.ts +++ b/src/server/schema/user.schema.ts @@ -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(), }); diff --git a/src/server/services/content.service.ts b/src/server/services/content.service.ts index bd5f0871c7..8ef1d05eeb 100644 --- a/src/server/services/content.service.ts +++ b/src/server/services/content.service.ts @@ -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()` 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 { +}): Promise { 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', }; } diff --git a/tests/preview-ssr-inject.spec.ts b/tests/preview-ssr-inject.spec.ts index b01dde9847..8d9648440d 100644 --- a/tests/preview-ssr-inject.spec.ts +++ b/tests/preview-ssr-inject.spec.ts @@ -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": }`. Procedures with no - // input (getFeatureFlags/checkTosUpdate) omit it entirely. + // string as the superjson envelope `{"json": }`. 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 domain→field 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 + * domain→field 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