diff --git a/src/shared/context/festipodDataBase.test.ts b/src/shared/context/festipodDataBase.test.ts
new file mode 100644
index 0000000..950cdd2
--- /dev/null
+++ b/src/shared/context/festipodDataBase.test.ts
@@ -0,0 +1,185 @@
+import { expect, test, describe } from 'bun:test';
+import { buildQueries, resolveParticipantUser, safeUserName, safeUserInitials, safeUserUsername } from './festipodDataBase';
+import type { FpEventData, FpUserData, FpParticipationData, FpMeetingPointData, FpFriendshipData } from '../data/types';
+
+describe('resolveParticipantUser', () => {
+ const users: FpUserData[] = [
+ { id: 'user-1', name: 'Marie Dupont', initials: 'MD', username: '@mariedupont' },
+ { id: 'http://localhost:3000/alice', name: 'Alice', initials: 'A', username: '@alice' },
+ ];
+
+ test('matches by exact profile id', () => {
+ expect(resolveParticipantUser('user-1', users)?.name).toBe('Marie Dupont');
+ expect(resolveParticipantUser('http://localhost:3000/alice', users)?.name).toBe('Alice');
+ });
+
+ test('matches the legacy urn:festipod:user: form on normalized username', () => {
+ expect(resolveParticipantUser('urn:festipod:user:mariedupont', users)?.name).toBe('Marie Dupont');
+ });
+
+ test('returns undefined when the participant profile is not available', () => {
+ expect(resolveParticipantUser('http://localhost:3000/bob', users)).toBeUndefined();
+ expect(resolveParticipantUser('urn:festipod:user:unknown', users)).toBeUndefined();
+ });
+});
+
+describe('getEventParticipants', () => {
+ const event: FpEventData = {
+ id: 'event-1',
+ title: 'Concert',
+ description: '',
+ date: '2026-10-10',
+ location: 'Lyon',
+ participantCount: 2,
+ };
+ const users: FpUserData[] = [
+ { id: 'user-1', name: 'Marie Dupont', initials: 'MD', username: '@mariedupont' },
+ { id: 'user-2', name: 'Jean Durand', initials: 'JD', username: '@jeandurand' },
+ ];
+ const participations: FpParticipationData[] = [
+ { id: 'p1', eventId: 'event-1', userId: 'user-1', isConfirmed: true },
+ { id: 'p2', eventId: 'event-1', userId: 'http://localhost:3000/bob', isConfirmed: true },
+ { id: 'p3', eventId: 'event-2', userId: 'user-2', isConfirmed: true },
+ ];
+ const queries = buildQueries(
+ [event],
+ users,
+ participations,
+ [] as FpMeetingPointData[],
+ [] as FpFriendshipData[],
+ 'user-1',
+ );
+
+ test('returns only participants whose profile resolved', () => {
+ const found = queries.getEventParticipants('event-1');
+ expect(found.map(u => u.id)).toEqual(['user-1']);
+ });
+
+ test('never exposes an unresolved attendee id as a name', () => {
+ const found = queries.getEventParticipants('event-1');
+ for (const u of found) {
+ expect(u.name).not.toMatch(/https?:\/\//);
+ expect(u.name).not.toMatch(/^\//);
+ expect(u.name).not.toMatch(/^urn:/);
+ expect(u.username).not.toMatch(/https?:\/\//);
+ }
+ });
+});
+
+describe('safeUserName', () => {
+ const user = (name: string): FpUserData => ({
+ id: 'u-1',
+ name,
+ initials: 'U',
+ username: '@user',
+ });
+
+ test('keeps a human-readable name', () => {
+ expect(safeUserName(user('Marie Dupont'))).toBe('Marie Dupont');
+ });
+
+ test('replaces an empty name with a neutral placeholder', () => {
+ expect(safeUserName(user(''))).toBe('Participant');
+ });
+
+ test('replaces a WebID/actor URI with a neutral placeholder', () => {
+ expect(safeUserName(user('http://localhost:3000/alice'))).toBe('Participant');
+ expect(safeUserName(user('https://example.com/as/actor/bob'))).toBe('Participant');
+ });
+
+ test('replaces an actor path with a neutral placeholder', () => {
+ expect(safeUserName(user('/as/actor/alice'))).toBe('Participant');
+ expect(safeUserName(user('/profiles/alice'))).toBe('Participant');
+ });
+
+ test('replaces a URN, DID, mailto or tel identifier with a neutral placeholder', () => {
+ expect(safeUserName(user('urn:festipod:user:alice'))).toBe('Participant');
+ expect(safeUserName(user('did:ng:abc123'))).toBe('Participant');
+ expect(safeUserName(user('mailto:alice@example.com'))).toBe('Participant');
+ expect(safeUserName(user('tel:+33123456789'))).toBe('Participant');
+ });
+
+ test('masks a resolved profile whose name happens to be a raw URI', () => {
+ const resolved: FpUserData = {
+ id: 'http://localhost:3000/alice',
+ name: 'http://localhost:3000/alice',
+ initials: 'HA',
+ username: '@alice',
+ };
+ expect(safeUserName(resolved)).toBe('Participant');
+ expect(safeUserInitials(resolved)).toBe('?');
+ });
+});
+
+describe('absent user and own-profile fallback', () => {
+ const user = (name: string): FpUserData => ({ id: 'u-1', name, initials: 'U', username: '@user' });
+
+ test('an absent user renders blank name and username, as before the sanitizers', () => {
+ expect(safeUserName(undefined)).toBe('');
+ expect(safeUserName(undefined, '')).toBe('');
+ expect(safeUserUsername(undefined)).toBe('');
+ });
+
+ test('an empty name stays empty when the caller passes an empty fallback (own profile)', () => {
+ expect(safeUserName(user(''), '')).toBe('');
+ expect(safeUserName(user(' '), '')).toBe('');
+ });
+
+ test('an identifier-shaped name is masked even with an empty fallback', () => {
+ expect(safeUserName(user('http://localhost:3000/alice'), '')).toBe('Participant');
+ });
+
+ test('a human name is unchanged whatever the fallback', () => {
+ expect(safeUserName(user('Marie Dupont'), '')).toBe('Marie Dupont');
+ });
+});
+
+describe('safeUserInitials', () => {
+ const user = (name: string, initials: string): FpUserData => ({
+ id: 'u-1',
+ name,
+ initials,
+ username: '@user',
+ });
+
+ test('keeps human initials', () => {
+ expect(safeUserInitials(user('Marie Dupont', 'MD'))).toBe('MD');
+ });
+
+ test('falls back to ? when the initials are missing or identifier-shaped', () => {
+ expect(safeUserInitials(user('Marie Dupont', ''))).toBe('?');
+ expect(safeUserInitials(user('Marie Dupont', '/as/actor/bob'))).toBe('?');
+ });
+
+ test('returns ? when the name is identifier-shaped (derived and explicit initials are indistinguishable)', () => {
+ expect(safeUserInitials(user('http://localhost:3000/alice', 'HT'))).toBe('?');
+ expect(safeUserInitials(user('http://localhost:3000/alice', 'AA'))).toBe('?');
+ expect(safeUserInitials(user('urn:festipod:user:bob', 'BB'))).toBe('?');
+ });
+
+ test('returns ? for an absent user', () => {
+ expect(safeUserInitials(undefined)).toBe('?');
+ });
+});
+
+describe('safeUserUsername', () => {
+ const user = (username: string): FpUserData => ({
+ id: 'u-1',
+ name: 'Nom',
+ initials: 'N',
+ username,
+ });
+
+ test('keeps a human handle', () => {
+ expect(safeUserUsername(user('@alice'))).toBe('@alice');
+ });
+
+ test('drops an identifier-shaped username', () => {
+ expect(safeUserUsername(user('http://localhost:3000/alice'))).toBe('');
+ expect(safeUserUsername(user('/as/actor/alice'))).toBe('');
+ });
+
+ test('drops an empty username', () => {
+ expect(safeUserUsername(user(''))).toBe('');
+ });
+});
diff --git a/src/shared/context/festipodDataBase.ts b/src/shared/context/festipodDataBase.ts
index eb8c30e..977a4b2 100644
--- a/src/shared/context/festipodDataBase.ts
+++ b/src/shared/context/festipodDataBase.ts
@@ -169,6 +169,63 @@ export const UNSET_PROFILE = {
username: '(pseudo non défini)',
} as const;
+// ── Display sanitizers: the UI must never render a raw identifier as a name ──
+// A participant's profile may resolve but still carry an empty name, a WebID,
+// an actor path, or any other identifier-shaped value instead of a human name.
+// These helpers are the last line of defence: they turn such values into a
+// neutral placeholder ("Participant", close to the "Participant inconnu" that
+// EventDetailScreen and ParticipantsListScreen show for unresolved attendees).
+// An ABSENT user (still loading / not resolved) is not masked: it renders as
+// before the sanitizers existed (blank name, blank username, '?' initials), and
+// screens showing the current user's own profile pass `emptyFallback: ''` so an
+// empty name stays blank (cf. UNSET_PROFILE for the unset-profile wording).
+
+/** True when `value` looks like an identifier rather than a human-readable name. */
+function looksLikeIdentifier(value: string): boolean {
+ const trimmed = value.trim();
+ if (trimmed.length === 0) return false; // empty is handled separately
+ // Any URI scheme (http:, mailto:, tel:, did:, urn:, ipfs:, …) or an absolute
+ // resource path (/as/actor/…, /profiles/…). Any leading `word:` is treated
+ // as a scheme, so a human name that starts that way ("DJ:Kool", "Lucie:)") is
+ // masked too — a known false positive, accepted for now. Only the START of
+ // the value is checked: a value embedding an identifier mid-string
+ // ("Bob https://…", "") is not masked.
+ return /^[a-z][a-z0-9+.-]*:/i.test(trimmed) || trimmed.startsWith('/');
+}
+
+/**
+ * A displayable name: the user's name unless it is empty or identifier-shaped.
+ * An absent user gives ''. `emptyFallback` is what an EMPTY name becomes
+ * ('Participant' by default; '' on the current user's own profile screens);
+ * an identifier-shaped name is always masked as 'Participant'.
+ */
+export function safeUserName(user: FpUserData | undefined, emptyFallback = 'Participant'): string {
+ if (!user) return '';
+ const name = user.name?.trim() ?? '';
+ if (name.length === 0) return emptyFallback;
+ return looksLikeIdentifier(name) ? 'Participant' : name;
+}
+
+/** A displayable username: the user's username unless it is identifier-shaped. */
+export function safeUserUsername(user: FpUserData | undefined): string {
+ const username = user?.username?.trim() ?? '';
+ if (username.length > 0 && !looksLikeIdentifier(username)) return username;
+ return '';
+}
+
+/**
+ * Safe initials for an avatar: explicit valid initials survive; otherwise '?'.
+ * An identifier-shaped name forces '?': initials derived from it ("HT" from
+ * "http://…") cannot be told apart from explicit ones.
+ */
+export function safeUserInitials(user: FpUserData | undefined): string {
+ if (!user) return '?';
+ if (looksLikeIdentifier(user.name ?? '')) return '?';
+ const initials = user.initials?.trim() ?? '';
+ if (initials.length > 0 && !looksLikeIdentifier(initials)) return initials;
+ return '?';
+}
+
/**
* The shared in-memory queries — the same logic for every provider, fed with
* that provider's arrays. Cross-entity derivations only: nothing here touches
diff --git a/src/shared/test-harness/screenSanitizers.test.tsx b/src/shared/test-harness/screenSanitizers.test.tsx
new file mode 100644
index 0000000..40bfcee
--- /dev/null
+++ b/src/shared/test-harness/screenSanitizers.test.tsx
@@ -0,0 +1,113 @@
+/**
+ * Screen-level wiring of the display sanitizers: renders the real screens
+ * (renderHelper + LocalDataProvider over the seed arrays, which are patched per
+ * test and restored) and asserts that no identifier-shaped value reaches the DOM.
+ */
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
+import { renderScreen, getRenderedDocument } from './renderHelper';
+import { seedUsers } from '../data/seedData';
+import type { FpUserData } from '../data/types';
+
+const ID = 'http://localhost:3000/alice';
+let originals: FpUserData[] = [];
+
+beforeEach(() => {
+ originals = seedUsers.map((u) => ({ ...u }));
+});
+afterEach(() => {
+ seedUsers.forEach((u, i) => Object.assign(u, originals[i]));
+});
+
+function patch(id: string, fields: Partial): void {
+ const u = seedUsers.find((x) => x.id === id);
+ if (!u) throw new Error(`seed user ${id} missing`);
+ Object.assign(u, fields);
+}
+
+async function textOf(screenId: string, path: string): Promise {
+ await renderScreen(screenId, path);
+ return getRenderedDocument()?.body.textContent ?? '';
+}
+
+describe('lists of other people mask identifier-shaped profiles', () => {
+ for (const [screenId, path] of [
+ ['participants', '/events/event-1/participants'],
+ ['event-detail', '/events/event-1'],
+ ] as const) {
+ test(`${screenId}: identifier name/username/initials never rendered`, async () => {
+ patch('user-2', { name: ID, username: ID, initials: 'HT' });
+ const text = await textOf(screenId, path);
+ expect(text).not.toContain('localhost');
+ expect(text).not.toContain('HT');
+ expect(text).toContain('Participant');
+ expect(text).toContain('?');
+ });
+ }
+});
+
+describe('someone else with an empty name', () => {
+ for (const [screenId, path] of [
+ ['participants', '/events/event-1/participants'],
+ ['event-detail', '/events/event-1'],
+ ['user-profile', '/users/user-2'],
+ ] as const) {
+ test(`${screenId}: shown as « Participant »`, async () => {
+ patch('user-2', { name: '' });
+ // Not the « Participants (N) » heading nor the « Participant inconnu » placeholder.
+ expect(await textOf(screenId, path)).toMatch(/Participant(?!s| inconnu)/);
+ });
+ }
+});
+
+describe('own profile screens', () => {
+ test('profile: identifier name/username/initials are not rendered', async () => {
+ patch('user-1', { name: ID, username: ID, initials: 'HT' });
+ const text = await textOf('profile', '/profile');
+ expect(text).not.toContain('localhost');
+ expect(text).not.toContain('HT');
+ });
+
+ test('profile: an empty name is not titled « Participant »', async () => {
+ patch('user-1', { name: '' });
+ expect(await textOf('profile', '/profile')).not.toContain('Participant');
+ });
+
+ test('share-profile: no identifier in the name, initials or festipod.app/u/ link', async () => {
+ patch('user-1', { name: ID, username: ID, initials: 'HT' });
+ const text = await textOf('share-profile', '/profile/share');
+ expect(text).not.toContain('localhost');
+ expect(text).not.toContain('HT');
+ expect(text).not.toContain('festipod.app/u/');
+ });
+
+ test('share-profile: a human username still builds the link; empty name is not « Participant »', async () => {
+ patch('user-1', { name: '' });
+ const text = await textOf('share-profile', '/profile/share');
+ expect(text).toContain('festipod.app/u/mariedupont');
+ expect(text).not.toContain('Participant');
+ });
+
+ test('edit-profile: identifier initials are not rendered in the avatar', async () => {
+ patch('user-1', { initials: ID });
+ expect(await textOf('edit-profile', '/profile/edit')).not.toContain('localhost');
+ });
+});
+
+describe('user-profile', () => {
+ test('another person with an identifier-shaped profile is masked', async () => {
+ patch('user-2', { name: ID, username: ID, initials: 'HT' });
+ const text = await textOf('user-profile', '/users/user-2');
+ expect(text).not.toContain('localhost');
+ expect(text).not.toContain('HT');
+ expect(text).toContain('Participant');
+ });
+
+ test('the current user viewed here with an empty name is not titled « Participant »', async () => {
+ patch('user-1', { name: '' });
+ expect(await textOf('user-profile', '/users/user-1')).not.toContain('Participant');
+ });
+
+ test('an unresolved user id renders no « Participant » placeholder', async () => {
+ expect(await textOf('user-profile', '/users/unknown-id')).not.toContain('Participant');
+ });
+});