From a28c57db6dc24b4d843821c3ca858ca2e1e41699 Mon Sep 17 00:00:00 2001 From: Kareem Naous <62202089+KareemN1936@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:20:07 +0300 Subject: [PATCH 1/2] feat(profile): show reputation badges and history --- apps/api/src/app.ts | 2 + apps/api/src/lib/user-profiles.ts | 207 +++++++++++++++++++ apps/api/src/routes/profiles.test.ts | 191 +++++++++++++++++ apps/api/src/routes/profiles.ts | 51 +++++ apps/web/src/app/profile/[username]/page.tsx | 190 ++++++++++++++++- apps/web/src/lib/api/profiles.ts | 59 ++++++ 6 files changed, 698 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/lib/user-profiles.ts create mode 100644 apps/api/src/routes/profiles.test.ts create mode 100644 apps/api/src/routes/profiles.ts create mode 100644 apps/web/src/lib/api/profiles.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 7c1bc65..756fc61 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -10,6 +10,7 @@ import { registerContributionRoutes } from './routes/contributions.js'; import { registerHealthRoute } from './routes/health.js'; import { registerLoreRoutes } from './routes/lore.js'; import { registerProposalRoutes } from './routes/proposals.js'; +import { registerProfileRoutes } from './routes/profiles.js'; import { registerWorldRoutes } from './routes/worlds.js'; export async function buildApp() { @@ -52,6 +53,7 @@ export async function buildApp() { await registerLoreRoutes(app); await registerContributionRoutes(app); await registerProposalRoutes(app); + await registerProfileRoutes(app); return app; } diff --git a/apps/api/src/lib/user-profiles.ts b/apps/api/src/lib/user-profiles.ts new file mode 100644 index 0000000..4aee0af --- /dev/null +++ b/apps/api/src/lib/user-profiles.ts @@ -0,0 +1,207 @@ +import { normalizeHiveUsername } from './hive-username.js'; + +export type ReputationLevel = { + badge: string; + label: string; + nextScore: number | null; +}; + +export type UserProfileDatabase = { + user: { + findUnique(args: unknown): Promise<{ + avatarUrl: string | null; + bio: string | null; + createdAt: Date; + displayName: string | null; + hiveUsername: string; + id: string; + normalizedHiveUsername: string; + contributionDrafts: Array<{ + id: string; + kind: string; + proposal: { id: string; status: string; submittedAt: Date | null } | null; + status: string; + submittedAt: Date | null; + title: string; + updatedAt: Date; + world: { id: string; slug: string; title: string }; + }>; + loreEntries: Array<{ + id: string; + loreType: string; + slug: string; + status: string; + title: string; + updatedAt: Date; + world: { id: string; slug: string; title: string }; + }>; + reputationSnapshots: Array<{ + breakdown: unknown; + calculatedAt: Date; + calculationVersion: string; + score: { toString(): string } | number | string; + }>; + votes: Array<{ + choice: string; + createdAt: Date; + proposal: { + id: string; + status: string; + title: string; + world: { id: string; slug: string; title: string }; + }; + }>; + } | null>; + }; +}; + +export function getReputationLevel(score: number): ReputationLevel { + if (score >= 250) { + return { badge: 'Worldsmith', label: 'Worldsmith', nextScore: null }; + } + + if (score >= 100) { + return { badge: 'Canon Keeper', label: 'Canon Keeper', nextScore: 250 }; + } + + if (score >= 25) { + return { badge: 'Contributor', label: 'Contributor', nextScore: 100 }; + } + + return { badge: 'Seedling', label: 'Seedling', nextScore: 25 }; +} + +function scoreToNumber(score: { toString(): string } | number | string) { + return Number(score.toString()); +} + +function serializeDate(value: Date | null) { + return value?.toISOString() ?? null; +} + +export async function getUserProfile(database: UserProfileDatabase, username: string) { + const normalizedHiveUsername = normalizeHiveUsername(username); + const user = await database.user.findUnique({ + where: { normalizedHiveUsername }, + select: { + avatarUrl: true, + bio: true, + createdAt: true, + displayName: true, + hiveUsername: true, + id: true, + normalizedHiveUsername: true, + contributionDrafts: { + orderBy: { updatedAt: 'desc' }, + select: { + id: true, + kind: true, + proposal: { select: { id: true, status: true, submittedAt: true } }, + status: true, + submittedAt: true, + title: true, + updatedAt: true, + world: { select: { id: true, slug: true, title: true } }, + }, + take: 10, + }, + loreEntries: { + orderBy: { updatedAt: 'desc' }, + select: { + id: true, + loreType: true, + slug: true, + status: true, + title: true, + updatedAt: true, + world: { select: { id: true, slug: true, title: true } }, + }, + take: 10, + }, + reputationSnapshots: { + orderBy: { calculatedAt: 'desc' }, + select: { + breakdown: true, + calculatedAt: true, + calculationVersion: true, + score: true, + }, + take: 1, + }, + votes: { + orderBy: { createdAt: 'desc' }, + select: { + choice: true, + createdAt: true, + proposal: { + select: { + id: true, + status: true, + title: true, + world: { select: { id: true, slug: true, title: true } }, + }, + }, + }, + take: 10, + }, + }, + }); + + if (!user) { + return null; + } + + const snapshot = user.reputationSnapshots[0] ?? null; + const score = snapshot ? scoreToNumber(snapshot.score) : 0; + + return { + user: { + avatarUrl: user.avatarUrl, + bio: user.bio, + createdAt: user.createdAt.toISOString(), + displayName: user.displayName, + hiveUsername: user.hiveUsername, + id: user.id, + normalizedHiveUsername: user.normalizedHiveUsername, + }, + reputation: { + breakdown: snapshot?.breakdown ?? null, + calculatedAt: serializeDate(snapshot?.calculatedAt ?? null), + calculationVersion: snapshot?.calculationVersion ?? null, + level: getReputationLevel(score), + score, + }, + history: { + contributions: user.contributionDrafts.map((draft) => ({ + id: draft.id, + kind: draft.kind, + proposal: draft.proposal + ? { + id: draft.proposal.id, + status: draft.proposal.status, + submittedAt: serializeDate(draft.proposal.submittedAt), + } + : null, + status: draft.status, + submittedAt: serializeDate(draft.submittedAt), + title: draft.title, + updatedAt: draft.updatedAt.toISOString(), + world: draft.world, + })), + loreEntries: user.loreEntries.map((entry) => ({ + id: entry.id, + loreType: entry.loreType, + slug: entry.slug, + status: entry.status, + title: entry.title, + updatedAt: entry.updatedAt.toISOString(), + world: entry.world, + })), + votes: user.votes.map((vote) => ({ + choice: vote.choice, + createdAt: vote.createdAt.toISOString(), + proposal: vote.proposal, + })), + }, + }; +} diff --git a/apps/api/src/routes/profiles.test.ts b/apps/api/src/routes/profiles.test.ts new file mode 100644 index 0000000..c2182f4 --- /dev/null +++ b/apps/api/src/routes/profiles.test.ts @@ -0,0 +1,191 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import Fastify from 'fastify'; + +import type { UserProfileDatabase } from '../lib/user-profiles.js'; +import { registerProfileRoutes } from './profiles.js'; + +const user = { + avatarUrl: null, + bio: 'Builder of small myths.', + createdAt: new Date('2026-08-01T10:00:00.000Z'), + displayName: 'Mira Vale', + hiveUsername: 'mira-vale.dev', + id: 'user-1', + normalizedHiveUsername: 'mira-vale.dev', +}; + +function createDatabase(): UserProfileDatabase { + return { + user: { + async findUnique(args) { + const query = args as { + where?: { normalizedHiveUsername?: string }; + }; + + if (query.where?.normalizedHiveUsername !== user.normalizedHiveUsername) { + return null; + } + + return { + ...user, + contributionDrafts: [ + { + id: 'draft-1', + kind: 'LORE', + proposal: { + id: 'proposal-1', + status: 'VOTING', + submittedAt: new Date('2026-08-03T12:00:00.000Z'), + }, + status: 'SUBMITTED', + submittedAt: new Date('2026-08-03T12:00:00.000Z'), + title: 'The amber gate opens', + updatedAt: new Date('2026-08-03T12:00:00.000Z'), + world: { id: 'world-1', slug: 'ember-city', title: 'Ember City' }, + }, + ], + loreEntries: [ + { + id: 'entry-1', + loreType: 'LOCATION', + slug: 'amber-gate', + status: 'PUBLISHED_CANON', + title: 'Amber Gate', + updatedAt: new Date('2026-08-04T12:00:00.000Z'), + world: { id: 'world-1', slug: 'ember-city', title: 'Ember City' }, + }, + ], + reputationSnapshots: [ + { + breakdown: { signals: { canonizedContributions: 1 } }, + calculatedAt: new Date('2026-08-05T12:00:00.000Z'), + calculationVersion: 'hivelore-reputation-v1', + score: 125, + }, + ], + votes: [ + { + choice: 'APPROVE', + createdAt: new Date('2026-08-06T12:00:00.000Z'), + proposal: { + id: 'proposal-2', + status: 'APPROVED_FOR_PUBLICATION', + title: 'A foundry oath', + world: { id: 'world-1', slug: 'ember-city', title: 'Ember City' }, + }, + }, + ], + }; + }, + }, + }; +} + +async function buildTestApp(database = createDatabase()) { + const app = Fastify(); + await registerProfileRoutes(app, { database }); + + return app; +} + +describe('profile routes', () => { + test('returns reputation, badge, and contribution history for a Hive user', async () => { + const app = await buildTestApp(); + + const response = await app.inject({ + method: 'GET', + url: '/profiles/Mira-Vale.dev', + }); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response.json(), { + history: { + contributions: [ + { + id: 'draft-1', + kind: 'LORE', + proposal: { + id: 'proposal-1', + status: 'VOTING', + submittedAt: '2026-08-03T12:00:00.000Z', + }, + status: 'SUBMITTED', + submittedAt: '2026-08-03T12:00:00.000Z', + title: 'The amber gate opens', + updatedAt: '2026-08-03T12:00:00.000Z', + world: { id: 'world-1', slug: 'ember-city', title: 'Ember City' }, + }, + ], + loreEntries: [ + { + id: 'entry-1', + loreType: 'LOCATION', + slug: 'amber-gate', + status: 'PUBLISHED_CANON', + title: 'Amber Gate', + updatedAt: '2026-08-04T12:00:00.000Z', + world: { id: 'world-1', slug: 'ember-city', title: 'Ember City' }, + }, + ], + votes: [ + { + choice: 'APPROVE', + createdAt: '2026-08-06T12:00:00.000Z', + proposal: { + id: 'proposal-2', + status: 'APPROVED_FOR_PUBLICATION', + title: 'A foundry oath', + world: { id: 'world-1', slug: 'ember-city', title: 'Ember City' }, + }, + }, + ], + }, + reputation: { + breakdown: { signals: { canonizedContributions: 1 } }, + calculatedAt: '2026-08-05T12:00:00.000Z', + calculationVersion: 'hivelore-reputation-v1', + level: { + badge: 'Canon Keeper', + label: 'Canon Keeper', + nextScore: 250, + }, + score: 125, + }, + user: { + avatarUrl: null, + bio: 'Builder of small myths.', + createdAt: '2026-08-01T10:00:00.000Z', + displayName: 'Mira Vale', + hiveUsername: 'mira-vale.dev', + id: 'user-1', + normalizedHiveUsername: 'mira-vale.dev', + }, + }); + }); + + test('returns 404 for an unknown profile', async () => { + const app = await buildTestApp(); + + const response = await app.inject({ + method: 'GET', + url: '/profiles/unknown.dev', + }); + + assert.equal(response.statusCode, 404); + assert.deepEqual(response.json(), { error: 'Profile not found.' }); + }); + + test('rejects invalid Hive usernames', async () => { + const app = await buildTestApp(); + + const response = await app.inject({ + method: 'GET', + url: '/profiles/not_valid', + }); + + assert.equal(response.statusCode, 400); + assert.deepEqual(response.json(), { error: 'Invalid Hive username.' }); + }); +}); diff --git a/apps/api/src/routes/profiles.ts b/apps/api/src/routes/profiles.ts new file mode 100644 index 0000000..8a2b2b5 --- /dev/null +++ b/apps/api/src/routes/profiles.ts @@ -0,0 +1,51 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; + +import { InvalidHiveUsernameError } from '../lib/hive-username.js'; +import { getUserProfile, type UserProfileDatabase } from '../lib/user-profiles.js'; + +const profileParamsSchema = z.object({ + username: z.string().min(1).max(64), +}); + +type RegisterProfileRoutesOptions = { + database?: UserProfileDatabase; +}; + +export async function registerProfileRoutes( + app: FastifyInstance, + options: RegisterProfileRoutesOptions = {}, +) { + const database = + options.database ?? ((await import('../lib/prisma.js')).prisma as UserProfileDatabase); + + app.get('/profiles/:username', async (request, reply) => { + const params = profileParamsSchema.safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid profile route.', + }); + } + + try { + const profile = await getUserProfile(database, params.data.username); + + if (!profile) { + return reply.code(404).send({ + error: 'Profile not found.', + }); + } + + return profile; + } catch (error) { + if (error instanceof InvalidHiveUsernameError) { + return reply.code(400).send({ + error: 'Invalid Hive username.', + }); + } + + throw error; + } + }); +} diff --git a/apps/web/src/app/profile/[username]/page.tsx b/apps/web/src/app/profile/[username]/page.tsx index 07be111..a621378 100644 --- a/apps/web/src/app/profile/[username]/page.tsx +++ b/apps/web/src/app/profile/[username]/page.tsx @@ -1,4 +1,11 @@ -import { PlaceholderPage } from '@/components/layout/placeholder-page'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { ApiError } from '@/lib/api/errors'; +import { getUserProfile } from '@/lib/api/profiles'; type ProfilePageProps = { params: Promise<{ username: string }>; @@ -6,6 +13,185 @@ type ProfilePageProps = { export default async function ProfilePage({ params }: ProfilePageProps) { const { username } = await params; + let profile: Awaited>; + + try { + profile = await getUserProfile(username); + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + notFound(); + } + + return ( + + Profile could not load + + {error instanceof ApiError + ? (error.body?.error ?? 'Unable to load this profile.') + : 'Unable to load this profile.'} + + + ); + } + + const displayName = profile.user.displayName ?? profile.user.hiveUsername; + const totalActivity = + profile.history.contributions.length + + profile.history.loreEntries.length + + profile.history.votes.length; + + return ( +
+
+
+

+ Contributor profile +

+

+ {displayName} +

+

@{profile.user.hiveUsername}

+ {profile.user.bio ? ( +

{profile.user.bio}

+ ) : null} +
+ + + + Reputation + Recognition only; voting weight is unchanged. + + +
{profile.reputation.score}
+
+ {profile.reputation.level.badge} + {profile.reputation.level.nextScore ? ( + Next at {profile.reputation.level.nextScore} + ) : null} +
+

+ {profile.reputation.calculatedAt + ? `Updated ${new Date(profile.reputation.calculatedAt).toLocaleDateString()}` + : 'No reputation snapshot yet.'} +

+
+
+
+ +
+ + +

Contributions

+

{profile.history.contributions.length}

+
+
+ + +

Canon entries

+

{profile.history.loreEntries.length}

+
+
+ + +

Votes

+

{profile.history.votes.length}

+
+
+
+ +
+ + {profile.history.contributions.map((contribution) => ( +
  • +
    + {contribution.kind} + + {contribution.status} + +
    +

    {contribution.title}

    +

    + + {contribution.world.title} + {' '} + · updated {new Date(contribution.updatedAt).toLocaleDateString()} +

    +
  • + ))} +
    + + + {profile.history.loreEntries.map((entry) => ( +
  • +
    + {entry.loreType} + {entry.status} +
    +

    {entry.title}

    +

    + + {entry.world.title} + {' '} + · updated {new Date(entry.updatedAt).toLocaleDateString()} +

    +
  • + ))} +
    +
    + + + {profile.history.votes.map((vote) => ( +
  • +
    + {vote.choice} + {vote.proposal.status} +
    +

    {vote.proposal.title}

    +

    + {vote.proposal.world.title} · voted {new Date(vote.createdAt).toLocaleDateString()} +

    +
  • + ))} +
    + + {totalActivity === 0 ? ( + + +

    + This contributor has no public contribution history yet. +

    +
    +
    + ) : null} +
    + ); +} + +function ProfileList({ children, title }: { children: React.ReactNode; title: string }) { + const items = Array.isArray(children) ? children.filter(Boolean) : children; + const isEmpty = Array.isArray(items) && items.length === 0; - return ; + return ( +
    +

    {title}

    + {isEmpty ? ( + + +

    Nothing to show yet.

    +
    +
    + ) : ( +
      {items}
    + )} +
    + ); } diff --git a/apps/web/src/lib/api/profiles.ts b/apps/web/src/lib/api/profiles.ts new file mode 100644 index 0000000..f87a528 --- /dev/null +++ b/apps/web/src/lib/api/profiles.ts @@ -0,0 +1,59 @@ +import { apiClient } from '@/lib/api/client'; + +export type UserProfile = { + user: { + avatarUrl: string | null; + bio: string | null; + createdAt: string; + displayName: string | null; + hiveUsername: string; + id: string; + normalizedHiveUsername: string; + }; + reputation: { + breakdown: unknown; + calculatedAt: string | null; + calculationVersion: string | null; + level: { + badge: string; + label: string; + nextScore: number | null; + }; + score: number; + }; + history: { + contributions: Array<{ + id: string; + kind: string; + proposal: { id: string; status: string; submittedAt: string | null } | null; + status: string; + submittedAt: string | null; + title: string; + updatedAt: string; + world: { id: string; slug: string; title: string }; + }>; + loreEntries: Array<{ + id: string; + loreType: string; + slug: string; + status: string; + title: string; + updatedAt: string; + world: { id: string; slug: string; title: string }; + }>; + votes: Array<{ + choice: string; + createdAt: string; + proposal: { + id: string; + status: string; + title: string; + world: { id: string; slug: string; title: string }; + }; + }>; + }; +}; + +export function getUserProfile(username: string) { + return apiClient.get(`/profiles/${encodeURIComponent(username)}`); +} From 4fdf70d5c5daf02c43a9fb549d0145090dc1908d Mon Sep 17 00:00:00 2001 From: Mohammad Farhat Date: Wed, 2 Sep 2026 12:48:45 +0000 Subject: [PATCH 2/2] fix(profile): stop leaking private drafts on the public profile endpoint GET /profiles/:username is unauthenticated, but the profile query filtered only on the user. contributionDrafts and loreEntries were selected with no status filter, so any visitor could read another user's unsubmitted draft titles and the world they were drafting in. - contributionDrafts: restrict to ContributionStatus.SUBMITTED - loreEntries: restrict to LoreStatus.PUBLISHED_CANON, so DRAFT, SUBMITTED, APPROVED_FOR_PUBLICATION and ARCHIVED stay private to the author The existing test stub returned fixture rows regardless of the query, so a missing filter could not surface. It now honours the nested `where` the way Prisma would, and the fixture carries an unsubmitted draft and a non-canon lore entry that the new test asserts are absent. Removing either filter fails two tests. Co-Authored-By: Claude Opus 5 --- apps/api/src/lib/user-profiles.ts | 5 ++ apps/api/src/routes/profiles.test.ts | 116 +++++++++++++++++++++------ 2 files changed, 96 insertions(+), 25 deletions(-) diff --git a/apps/api/src/lib/user-profiles.ts b/apps/api/src/lib/user-profiles.ts index 4aee0af..417085c 100644 --- a/apps/api/src/lib/user-profiles.ts +++ b/apps/api/src/lib/user-profiles.ts @@ -92,6 +92,8 @@ export async function getUserProfile(database: UserProfileDatabase, username: st id: true, normalizedHiveUsername: true, contributionDrafts: { + // Public endpoint: never expose unsubmitted drafts (ContributionStatus.DRAFT). + where: { status: 'SUBMITTED' }, orderBy: { updatedAt: 'desc' }, select: { id: true, @@ -106,6 +108,9 @@ export async function getUserProfile(database: UserProfileDatabase, username: st take: 10, }, loreEntries: { + // Public endpoint: only canon is public. DRAFT/SUBMITTED/APPROVED_FOR_PUBLICATION + // and ARCHIVED entries stay private to the author. + where: { status: 'PUBLISHED_CANON' }, orderBy: { updatedAt: 'desc' }, select: { id: true, diff --git a/apps/api/src/routes/profiles.test.ts b/apps/api/src/routes/profiles.test.ts index c2182f4..735ca29 100644 --- a/apps/api/src/routes/profiles.test.ts +++ b/apps/api/src/routes/profiles.test.ts @@ -22,41 +22,77 @@ function createDatabase(): UserProfileDatabase { async findUnique(args) { const query = args as { where?: { normalizedHiveUsername?: string }; + select?: { + contributionDrafts?: { where?: { status?: string } }; + loreEntries?: { where?: { status?: string } }; + }; }; + // Honour the nested relation filters the way Prisma would, so that a + // missing `where` in the query surfaces as leaked rows in the response. + const applyStatusFilter = ( + rows: T[], + filter: { status?: string } | undefined, + ) => (filter?.status ? rows.filter((row) => row.status === filter.status) : rows); + if (query.where?.normalizedHiveUsername !== user.normalizedHiveUsername) { return null; } return { ...user, - contributionDrafts: [ - { - id: 'draft-1', - kind: 'LORE', - proposal: { - id: 'proposal-1', - status: 'VOTING', + contributionDrafts: applyStatusFilter( + [ + { + id: 'draft-1', + kind: 'LORE', + proposal: { + id: 'proposal-1', + status: 'VOTING', + submittedAt: new Date('2026-08-03T12:00:00.000Z'), + }, + status: 'SUBMITTED', submittedAt: new Date('2026-08-03T12:00:00.000Z'), + title: 'The amber gate opens', + updatedAt: new Date('2026-08-03T12:00:00.000Z'), + world: { id: 'world-1', slug: 'ember-city', title: 'Ember City' }, }, - status: 'SUBMITTED', - submittedAt: new Date('2026-08-03T12:00:00.000Z'), - title: 'The amber gate opens', - updatedAt: new Date('2026-08-03T12:00:00.000Z'), - world: { id: 'world-1', slug: 'ember-city', title: 'Ember City' }, - }, - ], - loreEntries: [ - { - id: 'entry-1', - loreType: 'LOCATION', - slug: 'amber-gate', - status: 'PUBLISHED_CANON', - title: 'Amber Gate', - updatedAt: new Date('2026-08-04T12:00:00.000Z'), - world: { id: 'world-1', slug: 'ember-city', title: 'Ember City' }, - }, - ], + { + id: 'draft-secret', + kind: 'LORE', + proposal: null, + status: 'DRAFT', + submittedAt: null, + title: 'UNSUBMITTED private draft', + updatedAt: new Date('2026-08-07T12:00:00.000Z'), + world: { id: 'world-1', slug: 'ember-city', title: 'Ember City' }, + }, + ], + query.select?.contributionDrafts?.where, + ), + loreEntries: applyStatusFilter( + [ + { + id: 'entry-1', + loreType: 'LOCATION', + slug: 'amber-gate', + status: 'PUBLISHED_CANON', + title: 'Amber Gate', + updatedAt: new Date('2026-08-04T12:00:00.000Z'), + world: { id: 'world-1', slug: 'ember-city', title: 'Ember City' }, + }, + { + id: 'entry-secret', + loreType: 'LOCATION', + slug: 'unpublished-vault', + status: 'DRAFT', + title: 'UNPUBLISHED private lore', + updatedAt: new Date('2026-08-08T12:00:00.000Z'), + world: { id: 'world-1', slug: 'ember-city', title: 'Ember City' }, + }, + ], + query.select?.loreEntries?.where, + ), reputationSnapshots: [ { breakdown: { signals: { canonizedContributions: 1 } }, @@ -165,6 +201,36 @@ describe('profile routes', () => { }); }); + test('never exposes unsubmitted drafts or non-canon lore on the public profile', async () => { + const app = await buildTestApp(); + + const response = await app.inject({ + method: 'GET', + url: '/profiles/mira-vale.dev', + }); + + assert.equal(response.statusCode, 200); + + const body = response.json(); + const serialised = JSON.stringify(body); + + // The endpoint is unauthenticated, so private work must never appear. + assert.ok( + !serialised.includes('UNSUBMITTED private draft'), + 'unsubmitted contribution draft leaked on a public profile', + ); + assert.ok( + !serialised.includes('UNPUBLISHED private lore'), + 'non-canon lore entry leaked on a public profile', + ); + assert.ok(!serialised.includes('draft-secret'), 'unsubmitted draft id leaked'); + assert.ok(!serialised.includes('entry-secret'), 'non-canon lore id leaked'); + + // and the legitimately public rows are still present + assert.ok(serialised.includes('The amber gate opens')); + assert.ok(serialised.includes('Amber Gate')); + }); + test('returns 404 for an unknown profile', async () => { const app = await buildTestApp();