mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
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:
@@ -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();
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 });
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 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
|
||||
|
||||
Reference in New Issue
Block a user