diff --git a/CLAUDE.md b/CLAUDE.md
index 596307e..74047a5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -18,6 +18,7 @@ OSU campus social app for small-group hangouts ("pods"), clubs, messaging, and a
- **Verification gate:** almost everything requires a verified OSU email (`requireVerifiedAuth`). The App Store reviewer account bypasses it — don't break that path.
- **Deep links:** `oval://pod/:podId` etc., configured in `App.tsx`; push notification payloads carry a `url` that feeds the same config.
- **Age rating is 18+** and moderation features (block, report, delete account, data export) are App Store review-critical. Never weaken them casually.
+- **Terms version is a two-sided deploy.** `CURRENT_TERMS_VERSION` exists in both `backend/src/config/legal.ts` and `frontend/src/constants/legal.ts`. Bumping one without the other 400s every client on the terms screen with no way past it — this has broken a release twice. Old versions stay valid via `PREVIOUS_TERMS_VERSIONS`; deploy the backend before shipping the build. Full procedure: `docs/PLAYBOOK.md` §8.
## Workflow expectations
diff --git a/backend/package.json b/backend/package.json
index 761d5f2..329056f 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -18,6 +18,7 @@
"seed:screenshots": "ts-node prisma/seed-screenshots.ts",
"cleanup:screenshots": "ts-node prisma/cleanup-screenshots.ts",
"reseed:screenshots": "./prisma/reseed-screenshots.sh",
+ "check:screenshots": "ts-node prisma/check-screenshot-seed.ts",
"test": "vitest run",
"test:watch": "vitest",
"postinstall": "prisma generate"
diff --git a/backend/prisma/check-screenshot-seed.ts b/backend/prisma/check-screenshot-seed.ts
new file mode 100644
index 0000000..e09226a
--- /dev/null
+++ b/backend/prisma/check-screenshot-seed.ts
@@ -0,0 +1,112 @@
+///
+/**
+ * Read-only diagnostic for the screenshot seed.
+ *
+ * Answers: which database am I pointed at, did the activity-catalog migration
+ * land there, is any seed data actually present, and does my own account see it?
+ * Writes nothing — safe to run against production.
+ *
+ * npx ts-node prisma/check-screenshot-seed.ts
+ * CHECK_EMAIL=you@osu.edu npx ts-node prisma/check-screenshot-seed.ts
+ */
+import { PrismaClient } from '@prisma/client';
+import * as fs from 'fs';
+import * as path from 'path';
+
+const prisma = new PrismaClient();
+const MANIFEST_PATH = path.join(__dirname, '.screenshot-seed-manifest.json');
+
+function describeTarget() {
+ try {
+ const u = new URL(process.env.DATABASE_URL ?? '');
+ const user = decodeURIComponent(u.username || '');
+ const ref = user.includes('.') ? user.split('.').slice(1).join('.') : '(none)';
+ return { host: u.host, ref };
+ } catch {
+ return { host: '(unparseable DATABASE_URL)', ref: '(unknown)' };
+ }
+}
+
+async function main() {
+ const t = describeTarget();
+ console.log('=== TARGET ===');
+ console.log(' host :', t.host);
+ console.log(' project ref :', t.ref);
+ console.log(' (compare this ref to the DATABASE_URL in your Vercel project settings)');
+
+ console.log('\n=== MIGRATION STATE ===');
+ const cols: Array<{ column_name: string }> = await prisma.$queryRawUnsafe(
+ `SELECT column_name FROM information_schema.columns WHERE table_name = 'Activity'`
+ );
+ const names = cols.map((c) => c.column_name);
+ const hasCatalogV2 = ['isActive', 'artworkKey', 'sortOrder'].every((c) => names.includes(c));
+ console.log(' activity_catalog_v2 applied :', hasCatalogV2 ? 'YES' : 'NO <-- seed will throw without this');
+
+ if (hasCatalogV2) {
+ const needed = ['frisbee', 'trivia', 'study-group', 'grab-coffee-tea', 'casual-hangout', 'pickup-soccer'];
+ const found = await prisma.activity.findMany({
+ where: { artworkKey: { in: needed }, isActive: true },
+ select: { artworkKey: true },
+ });
+ const missing = needed.filter((k) => !found.some((f) => f.artworkKey === k));
+ console.log(' activities the seed needs :', `${found.length}/${needed.length} present`);
+ if (missing.length) console.log(' MISSING :', missing.join(', '));
+ }
+
+ console.log('\n=== WHAT IS ACTUALLY IN THIS DB ===');
+ const seedUsers = await prisma.user.count({ where: { email: { startsWith: 'oval-seed-' } } });
+ console.log(' demo users (oval-seed-*) :', seedUsers);
+ console.log(' users (total) :', await prisma.user.count());
+ console.log(' pods (total) :', await prisma.pod.count());
+ console.log(' clubs (total) :', await prisma.club.count());
+ console.log(' club members (total) :', await prisma.clubMember.count());
+ console.log(' friendships (total) :', await prisma.friendship.count());
+
+ console.log('\n=== MANIFEST vs DB ===');
+ if (!fs.existsSync(MANIFEST_PATH)) {
+ console.log(' no active manifest on disk (seed has not run, or cleanup already archived it)');
+ } else {
+ const m = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
+ console.log(' manifest createdAt :', m.createdAt);
+ const live = await prisma.user.count({ where: { id: { in: m.users ?? [] } } });
+ console.log(' manifest users still in DB :', `${live}/${(m.users ?? []).length}`);
+ const livePods = await prisma.pod.count({ where: { id: { in: m.pods ?? [] } } });
+ console.log(' manifest pods still in DB :', `${livePods}/${(m.pods ?? []).length}`);
+ const liveClubs = await prisma.club.count({ where: { id: { in: m.clubs ?? [] } } });
+ console.log(' manifest clubs still in DB :', `${liveClubs}/${(m.clubs ?? []).length}`);
+ }
+
+ const email = (process.env.CHECK_EMAIL ?? 'vanbibber.21@osu.edu').trim().toLowerCase();
+ console.log(`\n=== ACCOUNT: ${email} ===`);
+ const me = await prisma.user.findUnique({
+ where: { email },
+ select: {
+ id: true,
+ email: true,
+ accountStatus: true,
+ verifiedUniversity: true,
+ _count: { select: { clubMemberships: true } },
+ },
+ });
+ if (!me) {
+ console.log(' NOT FOUND in this database <-- the app you are testing uses a different DB');
+ } else {
+ console.log(' id :', me.id);
+ console.log(' accountStatus :', me.accountStatus);
+ console.log(' verifiedUniversity :', me.verifiedUniversity);
+ console.log(' club memberships :', me._count.clubMemberships);
+ const friends = await prisma.friendship.count({
+ where: { OR: [{ userAId: me.id }, { userBId: me.id }] },
+ });
+ console.log(' friendships :', friends);
+ }
+}
+
+main()
+ .catch((e) => {
+ console.error('\n[check] failed:', e.message);
+ process.exit(1);
+ })
+ .finally(async () => {
+ await prisma.$disconnect();
+ });
diff --git a/backend/src/config/legal.ts b/backend/src/config/legal.ts
index b8f3e5e..02a3f75 100644
--- a/backend/src/config/legal.ts
+++ b/backend/src/config/legal.ts
@@ -1 +1,39 @@
+/**
+ * Terms version tracking.
+ *
+ * CURRENT_TERMS_VERSION is what a freshly-updated client displays and what the
+ * app re-prompts against. ACCEPTED_TERMS_VERSIONS is what the API will still
+ * take from clients that haven't updated yet.
+ *
+ * WHY THE ALLOWLIST: the App Store build and the backend deploy are never
+ * simultaneous. If the API only accepted the current version, then between the
+ * two deploys every affected client would 400 on /auth/register and
+ * /auth/accept-terms — an inescapable wall on the terms screen, since the app
+ * re-prompts whenever the stored version differs. This has broken a release
+ * twice. See docs/PLAYBOOK.md → "Bumping the terms version".
+ *
+ * The version a user actually accepted is recorded as-submitted, never
+ * overwritten with CURRENT_TERMS_VERSION — a user on an older build saw the
+ * older text, and the record needs to reflect that.
+ *
+ * WHEN BUMPING: move the old value into PREVIOUS_TERMS_VERSIONS in the same
+ * commit. Drop entries only once that build is out of circulation (App Store
+ * Connect → force-upgrade cutoff), not before.
+ */
export const CURRENT_TERMS_VERSION = '2026-08-05';
+
+/**
+ * Older versions still honored during the upgrade window, newest first.
+ * '2026-06-20' ships in v1.1, which is live on the App Store.
+ */
+export const PREVIOUS_TERMS_VERSIONS = ['2026-06-20'] as const;
+
+export const ACCEPTED_TERMS_VERSIONS: readonly string[] = [
+ CURRENT_TERMS_VERSION,
+ ...PREVIOUS_TERMS_VERSIONS,
+];
+
+/** True if `version` is a version the API will accept from a client. */
+export function isAcceptedTermsVersion(version: unknown): version is string {
+ return typeof version === 'string' && ACCEPTED_TERMS_VERSIONS.includes(version.trim());
+}
diff --git a/backend/src/routes/auth.test.ts b/backend/src/routes/auth.test.ts
index 3198a71..deba72e 100644
--- a/backend/src/routes/auth.test.ts
+++ b/backend/src/routes/auth.test.ts
@@ -3,7 +3,7 @@ import request from 'supertest';
import app from '../server';
import prisma from '../prisma';
import { hashOneTimeCode } from '../lib/oneTimeCodes';
-import { CURRENT_TERMS_VERSION } from '../config/legal';
+import { CURRENT_TERMS_VERSION, PREVIOUS_TERMS_VERSIONS } from '../config/legal';
const TEST_DOMAIN = '@osu.edu';
const VALID_PROFILE = {
@@ -58,6 +58,43 @@ describe('POST /auth/register', () => {
.expect(400);
});
+ // Regression: the App Store build and the backend deploy are never
+ // simultaneous. A client on the previously-shipped terms version must still
+ // be able to register, or the terms screen becomes an inescapable wall
+ // between the two deploys. See docs/PLAYBOOK.md → "Bumping the terms version".
+ it.each(PREVIOUS_TERMS_VERSIONS)(
+ 'still registers a client on previous terms version %s',
+ async (oldVersion) => {
+ const email = `oldterms-test-register-${Date.now()}${TEST_DOMAIN}`;
+ const res = await request(app)
+ .post('/auth/register')
+ .send({
+ name: 'Old Build',
+ email,
+ password: 'password123',
+ ...VALID_PROFILE,
+ termsVersion: oldVersion,
+ })
+ .expect(201);
+
+ // Records what that build actually displayed, not the current version.
+ expect(res.body.user.termsVersion).toBe(oldVersion);
+ }
+ );
+
+ it('rejects an unrecognized terms version', async () => {
+ await request(app)
+ .post('/auth/register')
+ .send({
+ name: 'Bogus Terms',
+ email: `bogusterms-test-register-${Date.now()}${TEST_DOMAIN}`,
+ password: 'password123',
+ ...VALID_PROFILE,
+ termsVersion: '1999-01-01',
+ })
+ .expect(400);
+ });
+
it('accepts @buckeyemail.osu.edu email', async () => {
const email = `buckeye-test-register-${Date.now()}@buckeyemail.osu.edu`;
const res = await request(app)
@@ -452,4 +489,43 @@ describe('POST /auth/accept-terms', () => {
expect(response.body.user.termsVersion).toBe(CURRENT_TERMS_VERSION);
expect(response.body.user.ageAttestedAt).toEqual(expect.any(String));
});
+
+ it.each(PREVIOUS_TERMS_VERSIONS)(
+ 'still accepts a client on previous terms version %s',
+ async (oldVersion) => {
+ const email = `accept-old-terms-${Date.now()}${TEST_DOMAIN}`;
+ const registration = await request(app)
+ .post('/auth/register')
+ .send({ name: 'Old Client', email, password: 'password123', ...VALID_PROFILE })
+ .expect(201);
+
+ await prisma.user.update({
+ where: { email },
+ data: { termsVersion: null, termsAcceptedAt: null, ageAttestedAt: null },
+ });
+
+ const response = await request(app)
+ .post('/auth/accept-terms')
+ .set('Authorization', `Bearer ${registration.body.token}`)
+ .send({ termsAccepted: true, ageConfirmed: true, termsVersion: oldVersion })
+ .expect(200);
+
+ // The older build showed the older text, so that is what gets recorded.
+ expect(response.body.user.termsVersion).toBe(oldVersion);
+ }
+ );
+
+ it('rejects an unrecognized terms version', async () => {
+ const email = `accept-bogus-terms-${Date.now()}${TEST_DOMAIN}`;
+ const registration = await request(app)
+ .post('/auth/register')
+ .send({ name: 'Bogus Client', email, password: 'password123', ...VALID_PROFILE })
+ .expect(201);
+
+ await request(app)
+ .post('/auth/accept-terms')
+ .set('Authorization', `Bearer ${registration.body.token}`)
+ .send({ termsAccepted: true, ageConfirmed: true, termsVersion: 'not-a-version' })
+ .expect(400);
+ });
});
diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts
index ea1f2a9..a28acda 100644
--- a/backend/src/routes/auth.ts
+++ b/backend/src/routes/auth.ts
@@ -5,7 +5,7 @@ import { requireAuth, AuthRequest } from '../middleware/auth';
import { sendPasswordResetEmail, sendVerificationEmail } from '../lib/emailService';
import { getFullName, normalizeNameParts } from '../lib/userNames';
import { moderateTextContent } from '../lib/contentModeration';
-import { CURRENT_TERMS_VERSION } from '../config/legal';
+import { CURRENT_TERMS_VERSION, isAcceptedTermsVersion } from '../config/legal';
import { issueAuthToken } from '../lib/authSession';
import { consumeDurableRateLimit } from '../lib/durableRateLimit';
import { hashEmailIdentity, normalizeEmail } from '../lib/identity';
@@ -152,7 +152,7 @@ router.post('/register', async (req: Request, res: Response): Promise => {
return;
}
- if (!termsAccepted || !ageConfirmed || termsVersion !== CURRENT_TERMS_VERSION) {
+ if (!termsAccepted || !ageConfirmed || !isAcceptedTermsVersion(termsVersion)) {
res.status(400).json({
error: 'You must confirm you are 18 or older and accept the current Terms and Community Guidelines.',
});
@@ -207,7 +207,9 @@ router.post('/register', async (req: Request, res: Response): Promise => {
emailVerifyExpiry: expiry,
classYear,
major,
- termsVersion: CURRENT_TERMS_VERSION,
+ // Record what the client actually displayed, not CURRENT_TERMS_VERSION —
+ // an older build showed older text.
+ termsVersion,
termsAcceptedAt: new Date(),
ageAttestedAt: new Date(),
},
@@ -541,10 +543,13 @@ router.post('/resend-verification', requireAuth, async (req: AuthRequest, res: R
// POST /auth/accept-terms
router.post('/accept-terms', requireAuth, async (req: AuthRequest, res: Response): Promise => {
+ const submittedVersion =
+ typeof req.body?.termsVersion === 'string' ? req.body.termsVersion.trim() : '';
+
if (
req.body?.termsAccepted !== true ||
req.body?.ageConfirmed !== true ||
- req.body?.termsVersion !== CURRENT_TERMS_VERSION
+ !isAcceptedTermsVersion(submittedVersion)
) {
res.status(400).json({ error: 'Accept the current Terms and confirm you are 18 or older.' });
return;
@@ -554,7 +559,9 @@ router.post('/accept-terms', requireAuth, async (req: AuthRequest, res: Response
const updated = await prisma.user.update({
where: { id: req.user!.userId },
data: {
- termsVersion: CURRENT_TERMS_VERSION,
+ // Record what the client actually displayed, not CURRENT_TERMS_VERSION —
+ // an older build showed older text.
+ termsVersion: submittedVersion,
termsAcceptedAt: new Date(),
ageAttestedAt: new Date(),
},
diff --git a/docs/PLAYBOOK.md b/docs/PLAYBOOK.md
index bbb632e..af44728 100644
--- a/docs/PLAYBOOK.md
+++ b/docs/PLAYBOOK.md
@@ -30,7 +30,26 @@ Copy-paste starting points for recurring tasks. Each recipe lists the context to
> Read `INCIDENT_RESPONSE.md`. Check `/health` (touches the DB). Common causes: paused Supabase DB, missing env var (validated at boot by `productionEnv.ts` — check Vercel logs for the throw), stale cron scheduler, CORS_ORIGIN mismatch. Timezone bugs: remember everything is America/New_York.
-## 8. Design/UX pass
+## 8. Bumping the terms version
+
+> **This has broken a release twice. Read before touching `legal.ts`.**
+>
+> The version string lives in two places that must agree: `backend/src/config/legal.ts` and `frontend/src/constants/legal.ts`. The app re-prompts whenever a user's stored `termsVersion` differs from the frontend constant (`AuthContext.tsx`), and `/auth/register` + `/auth/accept-terms` validate what the client submits. So a client whose version the API doesn't recognize gets a 400 on the terms screen **with no way past it** — it blocks existing users, not just new signups.
+>
+> The App Store build and the Vercel deploy are never simultaneous, so some client is always on the old version. That's what `ACCEPTED_TERMS_VERSIONS` is for.
+>
+> **Procedure:**
+>
+> 1. Update the terms text (`landing/src/components/TermsOfUse.jsx`, `PrivacyPolicy.jsx`, `CommunityGuidelines.jsx`).
+> 2. In `backend/src/config/legal.ts`: set the new `CURRENT_TERMS_VERSION` **and move the old value into `PREVIOUS_TERMS_VERSIONS` in the same commit.**
+> 3. Set the identical string in `frontend/src/constants/legal.ts`.
+> 4. `cd backend && npm test -- auth` — the `PREVIOUS_TERMS_VERSIONS` cases must pass.
+> 5. **Deploy the backend first, then build the app.** Never the reverse: a build that ships ahead of the deploy sends a version the API has never heard of.
+> 6. Verify against production before submitting: register a throwaway account on the new build, and confirm the currently-live App Store build can still accept terms.
+>
+> Prune `PREVIOUS_TERMS_VERSIONS` only once the build carrying that version is out of circulation, not at the next bump. The accepted version is recorded as-submitted rather than forced to `CURRENT_TERMS_VERSION`, because a user on an older build genuinely saw older text.
+
+## 9. Design/UX pass
> Read `AUDIT_2026.md` Part 2 (D1–D7) and the Lumen 2.0 token table before proposing changes; wireframes live in `docs/wireframes-2026/` (regenerate: `node gen-wireframes.mjs .`). Constraints: blur only on chrome (dock/sheets), solid card surfaces, WCAG AA contrast, OSU scarlet accent.