Who I am comes from signing in; my profile is the document I own

The app derived its identity from a profile lookup and, when nothing matched,
picked somebody else. That is backwards: signing in returns who I am, and the
profile is looked up by it.

- Identity and profile are now two things. The identity is what
  `ensureIdentity()` returns: opaque, never rendered, never written, never
  passed to a data-layer call. The profile is Festipod's own object -- pseudo,
  name, initials -- in a document we create and write.
- "My profile" is the profile document I own, resolved through
  `listMyEntityDocs('protected')`. No username matching, no positional pick. A
  failed listing leaves the answer UNKNOWN rather than collapsing to "none".
- Having no profile now resolves to having no profile. Two impersonation
  fallbacks are gone, including one in `updateProfile` that would have written
  your pseudo into a stranger's document.
- A profile is created at sign-in when none exists. The shape makes name,
  initials and username mandatory, so it is written with placeholders that read
  as instructions -- never a plausible human name, never anything derived from
  the opaque identity.

Nothing succeeds in silence any more
`joinEvent` used to return without writing and without throwing when it could
not attribute the participation, while the screen announced success. It rejects
now, and the confirmation follows the write. Withdrawal likewise -- the doctrine
requires it to be authoritative. The host notification stops being written into
the joiner's own store, where its recipient could never read it, and the
optimistic notice shown to the wrong person goes with it.

The creator signs up through the common path: no owner branch anywhere, no
special case, the same deposit and the same derived count.
This commit is contained in:
Sylvain Duchesne
2026-08-16 13:50:50 +02:00
parent 53c0e095cf
commit df971df135
6 changed files with 317 additions and 97 deletions
@@ -0,0 +1,9 @@
# Doc-debt — app-architecture
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED src/shared/context/FestipodDataContext.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/screens/EventDetailScreen.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/user/screens/UpdateProfileScreen.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
+8
View File
@@ -0,0 +1,8 @@
# Doc-debt — data-layer
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED src/shared/context/FestipodDataContext.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/utils/currentPrincipal.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
@@ -40,23 +40,23 @@ export function EventDetailScreen() {
const handleToggleJoin = () => {
if (!eventId) return;
// The optimistic toast stays immediate (the overlay already reflects the
// change), but the write can genuinely FAIL — a participation document that
// cannot be recorded throws instead of reading empty forever, and an
// unconfirmed withdrawal throws too. Surface it rather than leave the user
// with a success message and nothing written.
// THE CONFIRMATION FOLLOWS THE WRITE. It used to be shown on the spot, before
// the call had settled, so a sign-up that wrote nothing still read as
// « Tu participes ». The screen's own list flips immediately anyway (the data
// layer's optimistic overlay), so nothing is lost by waiting for the truth.
const confirmed = (message: string, tone: 'success' | 'info') => () => showToast(message, tone);
const failed = (message: string) => (err: unknown) => {
console.error('[EventDetail] participation write failed:', err);
showToast(message, 'error');
};
if (joined) {
void Promise.resolve(leaveEvent(eventId))
.then(confirmed('Participation annulée', 'info'))
.catch(failed("La désinscription n'a pas pu être enregistrée"));
showToast('Participation annulée', 'info');
} else {
void Promise.resolve(joinEvent(eventId))
.then(confirmed('Tu participes à cet événement', 'success'))
.catch(failed("L'inscription n'a pas pu être enregistrée"));
showToast('Tu participes à cet événement', 'success');
}
};
@@ -18,9 +18,18 @@ export function UpdateProfileScreen() {
const handleSave = () => {
const fullName = `${firstName} ${lastName}`.trim();
const initials = `${firstName[0] ?? ''}${lastName[0] ?? ''}`.toUpperCase();
updateProfile({ name: fullName, initials, username, city, bio });
showToast('Profil mis à jour', 'success');
navigate('/profile');
// The edit REJECTS when no profile of mine is resolved — it refuses to write
// into someone else's document. So confirm and leave the screen only once the
// write has settled, never before.
void Promise.resolve(updateProfile({ name: fullName, initials, username, city, bio }))
.then(() => {
showToast('Profil mis à jour', 'success');
navigate('/profile');
})
.catch((err: unknown) => {
console.error('[UpdateProfile] profile write failed:', err);
showToast("Le profil n'a pas pu être enregistré", 'error');
});
};
return (
+276 -83
View File
@@ -10,8 +10,6 @@ import type {
import {
depositRegistration,
depositLeave,
buildNotification,
insertNotification,
readRegistrationNotifications,
materializeAttendance,
deleteParticipation,
@@ -116,24 +114,44 @@ function nextId(prefix: string): string {
// old form, or those participations render as "participant inconnu".
const USER_PRINCIPAL_PREFIX = 'urn:festipod:user:';
/** Waits between attempts at resolving the owned-event set (see its effect). */
/** Waits between attempts at resolving an owned-document set (see the effects). */
const OWNED_RETRY_BACKOFF_MS = [500, 1500, 4000];
/**
* Resolve a Participation's `fp:user` to its UserProfile across the TWO id spaces
* that meet at this join (the root cause of the "unknown participant" bug):
* • a Participation stores `urn:festipod:user:<normalized-identifier>` (the stable
* principal = `currentUserId`), while
* • a UserProfile's `id` is its `did:ng:` document NURI — never that principal.
* The bridge is the NORMALIZED IDENTIFIER, which equals `normalizeIdentifier(username)`
* for the matching profile (the exact equality `currentUser` resolution already uses).
* So: strip the principal prefix off the participation's userId, and compare the
* remainder to `normalizeIdentifier(profile.username)`. In demo/local mode both sides
* are the bare seed id (`user-1`), matched directly by `u.id === userId` — which is
* why the direct match is tried FIRST (the seed username `@mariedupont` would not
* normalize to `user-1`). A per-deposit materializer uid (`mint...`, e.g.
* `mrktnoke-rzd699dk`) is a THIRD, unrelated space: it identifies an inbox deposit
* for the count, never a user — it does not participate in this join.
* What a BRAND-NEW profile is created with.
*
* A profile is entirely Festipod's own object — the data layer knows nothing of
* pseudos or display names — but the UserProfile SHEX shape makes `fp:name`,
* `fp:initials` and `fp:username` MANDATORY, so a profile cannot be written
* empty. And nothing about the person is known at sign-in: the identity the
* session signed in as is OPAQUE (no display name; it is never parsed, never
* rendered, never written into an entity). So the three required fields carry a
* PLACEHOLDER that reads on screen as "not filled in yet": none of them is a
* person's name or handle, and none is derived from the identity. The user
* replaces them through `updateProfile` (UpdateProfileScreen).
*/
const UNSET_PROFILE = {
name: 'Profil à compléter',
initials: '?',
username: '(pseudo non défini)',
} as const;
/**
* Resolve a Participation's `fp:user` to its UserProfile.
*
* TODAY'S WRITES need no resolving: `fp:user` carries the profile's own document
* NURI, so the DIRECT match `u.id === userId` answers — and it is tried first.
* The demo/local fixtures coincide there too (both sides are the bare `user-1`).
*
* LEGACY ONLY: participations written under the earlier scheme carry
* `urn:festipod:user:<normalized-handle>`, which matches no profile id. For those
* — and ONLY those — the handle is stripped off and compared to
* `normalizeIdentifier(profile.username)`. This username bridge is a READ-side
* survival for old data; it plays NO part in deciding who the current user is
* (my profile is the profile document I own — see the "WHO AM I" block below).
*
* A per-deposit materializer uid (`mint...`, e.g. `mrktnoke-rzd699dk`) is a third,
* unrelated space: it identifies an inbox deposit for the count, never a user.
*/
function resolveParticipantUser(userId: string, users: FpUserData[]): FpUserData | undefined {
// 1) Direct id match — demo/local seed space (`user-1`), or any coincident space.
@@ -304,10 +322,10 @@ function useNgData(): FestipodDataContextValue {
const eventQuery = useShapeQuery(FpEventShapeType, 'public');
const userQuery = useShapeQuery(FpUserProfileShapeType, 'protected');
const partQuery = useShapeQuery(FpParticipationShapeType, 'protected');
const users = React.useMemo(() => adaptUsers(userQuery.data), [userQuery.data]);
// The RAW reactive sets straight from `watchShape` (before the optimistic
// overlay). The exposed `events`/`participations` merge these with the pending
// overlay below — see the "OPTIMISTIC OVERLAY" block.
// overlay). The exposed `events`/`users`/`participations` merge these with the
// pending overlay below — see the "OPTIMISTIC OVERLAY" block.
const reactiveUsers = React.useMemo(() => adaptUsers(userQuery.data), [userQuery.data]);
const reactiveEvents = React.useMemo(() => adaptEvents(eventQuery.data), [eventQuery.data]);
const reactiveParticipations = React.useMemo(
() => adaptParticipations(partQuery.data),
@@ -331,9 +349,17 @@ function useNgData(): FestipodDataContextValue {
// dropped from pendingRemoves. No re-query, no interval — the overlay only reacts
// to `watchShape`'s own pushes.
const [pendingAddEvents, setPendingAddEvents] = useState<FpEventData[]>([]);
const [pendingAddUsers, setPendingAddUsers] = useState<FpUserData[]>([]);
const [pendingAddParticipations, setPendingAddParticipations] = useState<FpParticipationData[]>([]);
const [pendingRemoveIds, setPendingRemoveIds] = useState<Set<string>>(() => new Set());
const users = React.useMemo(() => {
if (pendingAddUsers.length === 0) return reactiveUsers;
const seen = new Set(reactiveUsers.map(u => u.id));
const extra = pendingAddUsers.filter(u => !seen.has(u.id));
return extra.length ? [...reactiveUsers, ...extra] : reactiveUsers;
}, [reactiveUsers, pendingAddUsers]);
const events = React.useMemo(() => {
if (pendingAddEvents.length === 0) return reactiveEvents;
const seen = new Set(reactiveEvents.map(e => e.id));
@@ -365,6 +391,15 @@ function useNgData(): FestipodDataContextValue {
});
}, [reactiveEvents, pendingAddEvents]);
useEffect(() => {
if (pendingAddUsers.length === 0) return;
const live = new Set(reactiveUsers.map(u => u.id));
setPendingAddUsers(prev => {
const next = prev.filter(u => !live.has(u.id));
return next.length === prev.length ? prev : next;
});
}, [reactiveUsers, pendingAddUsers]);
useEffect(() => {
if (pendingAddParticipations.length === 0) return;
const live = new Set(reactiveParticipations.map(p => p.id));
@@ -406,6 +441,17 @@ function useNgData(): FestipodDataContextValue {
// and writes `participantCount` on THAT (owned) doc — never on someone else's.
const [ownedEventIds, setOwnedEventIds] = useState<Nuri[]>([]);
// WHICH PROTECTED DOCUMENTS ARE MINE — the ground on which "my profile" rests.
// `null` means NOT ANSWERED YET (the listing has not landed, or it failed):
// "unknown" and "I own nothing" are indistinguishable as an empty array, and
// only one of them may lead to creating a profile. Nothing downstream may read
// `null` as an empty set.
const [myProtectedDocs, setMyProtectedDocs] = useState<Nuri[] | null>(null);
// The profile document THIS session created for the signed-in person. Set once,
// by the creation effect; it settles "which of my profile documents is mine"
// without looking at any field of any profile.
const [myProfileDocId, setMyProfileDocId] = useState<string>('');
/**
* Fold documents THIS SESSION just created into the owned set.
*
@@ -501,7 +547,12 @@ function useNgData(): FestipodDataContextValue {
if (hasTriedAutoSeed.current) return;
if (!ready) return;
if (!readReady) return; // still syncing — do NOT mistake pending for empty
const walletHasData = events.length > 0 || users.length > 0;
// MY OWN PROFILE IS NOT "DATA IN THE WALLET". It is created at sign-in on a
// brand-new wallet, so counting it here would permanently disable the seed on
// exactly the wallets it exists for. The question the gate asks is "does this
// wallet already hold something worth preserving", and my own empty profile
// does not.
const walletHasData = events.length > 0 || users.some(u => u.id !== myProfileDocId);
if (!shouldAutoSeed(walletHasData)) {
console.log(`${logPrefix} Auto-seed (FESTIPOD_AUTO_SEED): wallet already has data — skip`);
return;
@@ -516,40 +567,51 @@ function useNgData(): FestipodDataContextValue {
// The reactive `watchShape` reads pick the seeded per-entity docs up on their
// own (each createEntityDoc appends to the scope index → the container-index
// subscription re-resolves → the new docs enter the read). No registerDoc/relist.
}, [ready, readReady, events.length, users.length, claimOwnedEventDocs]);
}, [ready, readReady, events.length, users, myProfileDocId, claimOwnedEventDocs]);
// --- Derived ---
// WHO AM I — answered in TWO id spaces that must not be confused.
//
// (1) `currentPrincipal` — what signing in returned. Known as soon as the one
// identity await settles, i.e. before any document has been read. It names
// a PERSON. It is for display and log attribution; no data call takes it,
// and it is never written into an entity.
// (2) `currentUserId` — the app's own entity space: the `@id` of the profile
// DOCUMENT read back in the protected scope (a doc NURI). This is what a
// Participation's `fp:user` carries and what `resolveParticipantUser`
// matches directly, so it is the only value a mutation may write. It stays
// empty until the protected read lands — mutations that need it refuse
// rather than write an entity the read would drop.
// a PERSON, OPAQUELY: it is never parsed, never rendered as a name, never
// written into an entity and never handed to a data call. It exists here
// for display of "am I signed in" and for log attribution.
// (2) `currentUserId` — the app's own entity space: the `@id` of MY PROFILE
// DOCUMENT (a doc NURI). This is what a Participation's `fp:user` carries
// and what `resolveParticipantUser` matches directly, so it is the only
// value a mutation may write.
//
// THE JOIN between the two is explicit and lives HERE, in one place: a profile
// belongs to the signed-in person when its username normalizes to the
// principal — the same bridge `resolveParticipantUser` uses for the legacy
// `urn:festipod:user:` space. Nothing merges the spaces: the principal selects
// a profile, it never stands in for one.
// THERE IS NO JOIN BETWEEN THE TWO, and there must not be one. A profile is
// Festipod's own object; the identity says nothing about it. **My profile is
// the profile document I OWN** — `listMyEntityDocs('protected')` answers "which
// documents are mine", and the UserProfile among them is mine. No field of any
// profile takes part in the answer: no username comparison, no normalization,
// no positional pick.
//
// THREE OUTCOMES, and "somebody else's profile" is not one of them:
// • exactly one owned profile → that is me;
// • none → I have no profile yet, and the creation effect below makes one
// (until it lands, `currentUserId` is '' and the mutations that need it
// REJECT — they never write an entity keyed on nobody);
// • several, none of them created by this session → genuinely AMBIGUOUS (the
// opt-in fixture seed writes its profiles into my own protected scope), so
// the answer is NO PROFILE, said loudly. Picking one would be picking a
// person at random and calling them "you".
const currentPrincipal = useCurrentPrincipal();
const currentUser =
(currentPrincipal
? users.find(u => u.username && normalizeIdentifier(u.username) === currentPrincipal)
: undefined)
// No profile answers to the signed-in person (the wallet holds fixtures, or
// the profile read has not landed): fall back to the demo-seed pick. KNOWN
// HAZARD — this GUESSES a profile, so the app can show the wrong person as
// "you" while the real answer has simply not been read yet. Note what is and
// is not guessed: the identity itself never is (it is exactly what
// `ensureIdentity()` returned); only the profile it selects can be wrong.
|| users.find(u => u.username === '@mariedupont')
|| users[0];
/** The profiles I own: the reactive profiles whose document is one of mine. */
const myOwnedProfiles = React.useMemo<FpUserData[] | null>(() => {
if (myProtectedDocs === null) return null; // UNKNOWN — never "none"
const mine = new Set<string>(myProtectedDocs);
return users.filter(u => mine.has(u.id));
}, [users, myProtectedDocs]);
const currentUser = React.useMemo<FpUserData | undefined>(() => {
// This session created it → no ambiguity possible, whatever else is owned.
if (myProfileDocId) return users.find(u => u.id === myProfileDocId);
if (myOwnedProfiles === null) return undefined; // not answered yet
if (myOwnedProfiles.length === 1) return myOwnedProfiles[0];
return undefined; // none, or ambiguous
}, [users, myProfileDocId, myOwnedProfiles]);
const currentUserId = currentUser?.id || '';
// Identity-first log prefix, reused by every DATA log below (including the
// closures defined earlier in this function body — they only execute after
@@ -573,6 +635,106 @@ function useNgData(): FestipodDataContextValue {
}
const selectedUser = users.find(u => u.id === selectedUserId);
// --- MY PROFILE: which documents are mine ----------------------------------
// Resolve the PROTECTED documents this identity owns. That set is the whole
// basis of "which profile is mine", and it is also what decides whether a
// profile has to be CREATED — so a failure here must never look like an answer:
// a rejection means UNKNOWN, and reading it as "I own nothing" would create a
// second profile for someone who already has one. Retried; if it still will not
// answer, the set stays `null` (no profile resolved, no profile created) and it
// is said loudly.
useEffect(() => {
if (!ready) return;
let cancelled = false;
(async () => {
for (let attempt = 0; !cancelled; attempt++) {
try {
const mine = await listMyEntityDocs('protected');
if (cancelled) return;
setMyProtectedDocs(prev => [...new Set([...(prev ?? []), ...mine])]);
return;
} catch (err) {
const wait = OWNED_RETRY_BACKOFF_MS[attempt];
if (wait === undefined) {
console.error(
`${logPrefix} my-protected-documents resolution FAILED after ` +
`${OWNED_RETRY_BACKOFF_MS.length + 1} attempts — which profile is mine is UNKNOWN, ` +
`not absent: no profile will be resolved and none will be created until it is known:`,
err,
);
return;
}
console.warn(
`${logPrefix} my-protected-documents resolution failed (attempt ${attempt + 1}) — ` +
`retrying in ${wait}ms:`,
err,
);
await new Promise(r => setTimeout(r, wait));
}
}
})();
return () => { cancelled = true; };
}, [ready]);
// --- MY PROFILE: create one when I have none -------------------------------
// A profile is Festipod's own object, and signing in produces none — so the
// first time a person signs in, the app makes theirs. Gated on BOTH the
// protected read having settled (`userQuery.isSuccess`: synced-and-empty, not
// still-syncing) and the owned-document set being KNOWN, because "I have no
// profile" is only true when both have answered. Single-shot per session; on
// failure the guard is released, so a later change to the owned set retries.
const hasTriedProfileCreate = useRef(false);
useEffect(() => {
if (!ready) return;
if (hasTriedProfileCreate.current) return;
if (!userQuery.isSuccess) return;
if (myOwnedProfiles === null) return; // UNKNOWN — never read as "none"
if (myOwnedProfiles.length > 0) return; // I already have one (or more)
hasTriedProfileCreate.current = true;
(async () => {
console.log(`${logPrefix} no profile of mine — creating one (fields left visibly unset)`);
const graph = await createEntityDoc('protected');
// The three fields the UserProfile shape makes mandatory, written with the
// "not filled in yet" placeholders — nothing here comes from the identity.
await writeEntity(graph, ENTITY_TYPE.user, {
name: str(UNSET_PROFILE.name),
initials: str(UNSET_PROFILE.initials),
username: str(UNSET_PROFILE.username),
});
// This document is MINE — claimed explicitly, so it is recognized as my
// profile whatever else the protected scope holds (fixtures included).
setMyProfileDocId(graph);
setMyProtectedDocs(prev => [...new Set([...(prev ?? []), graph])]);
// OPTIMISTIC OVERLAY, same pattern as events/participations: surface the
// profile immediately so `currentUserId` resolves without waiting for the
// broker push; the reconciliation effect drops it once the read carries it.
const optimisticProfile: FpUserData = { id: graph, ...UNSET_PROFILE };
setPendingAddUsers(prev => (prev.some(u => u.id === graph) ? prev : [...prev, optimisticProfile]));
})().catch(err => {
hasTriedProfileCreate.current = false;
console.error(`${logPrefix} creating my profile FAILED — this session has no profile:`, err);
});
}, [ready, userQuery.isSuccess, myOwnedProfiles]);
// --- MY PROFILE: say it when the answer is ambiguous ------------------------
// Several profile documents are mine and none was created by this session (the
// opt-in fixture seed writes its profiles into my own protected scope). There
// is no honest way to tell which one is the person at the keyboard, so nothing
// is picked — `currentUser` stays undefined and the mutations that need it
// refuse. Silence here would look exactly like "the read has not landed".
const warnedAmbiguousProfile = useRef(false);
useEffect(() => {
if (myProfileDocId) return;
if (myOwnedProfiles === null || myOwnedProfiles.length <= 1) return;
if (warnedAmbiguousProfile.current) return;
warnedAmbiguousProfile.current = true;
console.error(
`${logPrefix} ${myOwnedProfiles.length} profile documents are mine and none was created by ` +
`this session — which one is me cannot be told apart, so NO profile is resolved (a fixture ` +
`seed run on this wallet is the usual cause). Sign-up and profile edition will refuse.`,
);
}, [myOwnedProfiles, myProfileDocId]);
// --- OWNER MATERIALIZER (Option B, brief §B.2 + T02.c notifications) -------
// The event OWNER's session materializes its OWN events' inbox deposits into
// (1) the correct `participantCount` on its OWN event doc, and
@@ -866,14 +1028,19 @@ function useNgData(): FestipodDataContextValue {
const joinEvent = useCallback(async (eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log(`${logPrefix} joinEvent (NG):`, eventId, 'user:', uid);
// A Participation MUST carry a user principal (SHEX `fp:user` is mandatory) —
// writing one without it produces an entity the ORM drops on read (the
// participation silently never round-trips). Refuse an empty principal rather
// than persist a broken participation. The caller resolves a real user id (the
// current user's IRI) before joining.
// A Participation MUST carry a user reference (SHEX `fp:user` is mandatory) —
// writing one without it produces an entity the read drops (the participation
// silently never round-trips). REJECT rather than return: returning here made
// the sign-up a no-op that wrote nothing, threw nothing and let the screen
// congratulate the user. The cause is named, because it is actionable: no
// profile of mine is resolved yet.
if (!uid) {
console.error(`${logPrefix} joinEvent: empty user principal — refusing to write a participation with no fp:user.`);
return;
const msg =
`joinEvent refused for event=${canonicalEventId(eventId)}: no profile of mine is resolved, ` +
`so a Participation would carry no fp:user and would never round-trip. ` +
`Wait for the profile to be created/read, or resolve the ambiguity reported above.`;
console.error(`${logPrefix} ${msg}`);
throw new Error(msg);
}
// IDEMPOTENCE — check AUTHORITATIVELY against the broker, not the reactive set.
// The reactive participation set can lag a just-written participation, so a
@@ -921,16 +1088,24 @@ function useNgData(): FestipodDataContextValue {
// The joiner's sole writes are: their OWN participation doc (above) + the
// inbox DEPOSIT (below). While the owner is offline the count doesn't advance
// for others — accepted eventual behaviour (brief §E.2); nothing is lost.
// 2) Notify the host: deposit into the event/host inbox via the GENERIC lib
// inbox (T02.b) + mint the host FpNotification (T02.a). `from` = registrant
// when connected, anonymous (null) otherwise. Best-effort: a failed deposit
// must not roll back a successful registration.
// 2) Tell the host. THE DEPOSIT IS THE DELIVERY: `inbox.postToDocument(doc, …)`
// is what the surface publishes for reaching a document's owner — anyone
// may deposit, only the owner reads. The host's notification is then built
// on the OWNER's side, out of the deposits it reads from its own event's
// inbox (`readRegistrationNotifications`, in the materializer above).
//
// Nothing else is written here. A host FpNotification used to be minted at
// this point into the JOINER's own protected scope with `recipient` set to
// the event — a document the host cannot read, and never will: the joiner
// owns it and the surface has no way to hand it over. It also pushed that
// notification into THIS session's own list, so the joiner saw a
// "new participant" notice addressed to someone else. Both are gone; the
// deposit alone carries the news, and it reaches its reader.
//
// Best-effort: a failed deposit must not roll back a successful
// registration (the participation document is already written).
try {
const registrantId = uid || null; // no current user → anonymous deposit
// Recipient = the event host. The Event shape carries no host IRI yet, so
// we key the host inbox/notification on the eventId (the host of THAT
// event). This is the domain injection the generic lib deliberately omits.
const recipientId = eventId;
// The event's `@id` IS its document NURI, and a deposit NAMES that document
// — the joiner resolves no inbox and holds no address.
// Carry the joiner's participation-doc NURI so the owner (if a connection)
@@ -940,24 +1115,11 @@ function useNgData(): FestipodDataContextValue {
`event=${canonicalEventId(eventId)} user=${uid} (count now moves via the OWNER ` +
`materializing this deposit on its own doc, at its next connection)`,
);
const { ts, uid: depositUid } = await depositRegistration(eventId, registrantId, partGraph);
const { uid: depositUid } = await depositRegistration(eventId, registrantId, partGraph);
// Remember the join uid so a same-session leave can cancel it precisely.
joinUidsRef.current.set(`${eventId}|${uid}`, depositUid);
const notif = buildNotification(recipientId, eventId, registrantId, ts);
// The host FpNotification is its OWN document in the PROTECTED scope (one
// doc per entity). The inbox materialization remains the source of truth;
// this direct write only pre-warms the reactive read — but a FAILED write is
// not swallowed: it used to be dropped silently, and the line below then
// surfaced a notification nothing had recorded. The rejection reaches the
// catch under this block, which names it.
const notifGraph = await createEntityDoc('protected');
await insertNotification(notifGraph, notif);
// Surface immediately in reactive state (materialization also refreshes it).
// Use the stable per-deposit uid for the id (F5 dedup) so it matches the
// notification id from the inbox and same-ms/anon deposits never collide.
setNotifications(prev => [...prev, { ...notif, id: `notif-${depositUid}` }]);
} catch (err) {
console.error(`${logPrefix} joinEvent inbox/notify failed:`, err);
console.error(`${logPrefix} joinEvent inbox deposit failed:`, err);
}
}, [events, currentUserId]);
@@ -968,7 +1130,22 @@ function useNgData(): FestipodDataContextValue {
// document (writeEntity uses the doc NURI as the subject), so `part.id` is BOTH
// the subject IRI AND the graph NURI it lives in.
const part = participations.find(p => p.eventId === eventId && p.userId === uid);
if (!part) return;
// REJECT rather than pretend. Withdrawal is AUTHORITATIVE (see
// caveat_participation-deletion): the caller only reaches here because it
// believes a participation exists, so finding none is a real disagreement
// about the state — either no profile of mine is resolved (`uid` empty), or
// the participation the screen showed is not in the set. Returning silently
// deleted nothing while the screen announced a withdrawal, and the sign-up
// came back on the next read.
if (!part) {
const msg = uid
? `leaveEvent refused for event=${canonicalEventId(eventId)}: no participation of user=${uid} ` +
`in the participation set — nothing was deleted, so the withdrawal must not be announced.`
: `leaveEvent refused for event=${canonicalEventId(eventId)}: no profile of mine is resolved, ` +
`so the participation to withdraw cannot even be named.`;
console.error(`${logPrefix} ${msg}`);
throw new Error(msg);
}
// DÉSINSCRIPTION FIX (caveat_participation-deletion): the AUTHORITATIVE deletion
// is a SPARQL DELETE via the real injected `ng` (docs.sparqlUpdate), which
// removes the Participation server-side so it does NOT resurrect after re-sync.
@@ -1054,9 +1231,19 @@ function useNgData(): FestipodDataContextValue {
const updateProfile = useCallback(async (updates: Partial<FpUserData>) => {
console.log(`${logPrefix} updateProfile (NG):`, updates);
// The current user's profile is its own document (subject IRI = doc NURI).
const target = currentUser ?? users[0];
if (!target) return;
// MY profile is its own document (subject IRI = doc NURI), and it is the ONLY
// document these edits may land in. There used to be a `?? users[0]` fallback
// here: with no profile resolved, a person editing their own pseudo wrote it
// into a stranger's profile document. Refuse instead — and say which of the
// two reasons it is, since only one of them clears up on its own.
const target = currentUser;
if (!target) {
const msg =
`updateProfile refused: no profile of mine is resolved, and these edits must never land in ` +
`someone else's profile document.`;
console.error(`${logPrefix} ${msg}`);
throw new Error(msg);
}
const graph = target.id;
const persists: Promise<void>[] = [];
if (updates.name !== undefined) persists.push(updateEntityField(graph, graph, 'name', str(updates.name)));
@@ -1073,7 +1260,10 @@ function useNgData(): FestipodDataContextValue {
// seed runs (marking the guard at the START, before the awaited seed, closes
// the window where the auto-seed effect could also fire on a still-empty read).
hasTriedAutoSeed.current = true;
const walletHasData = events.length > 0 || users.length > 0;
// Same reading as the auto-seed gate: my own profile is not "data in the
// wallet" — it exists on every wallet from the first sign-in, so counting it
// would make an explicit load a permanent no-op.
const walletHasData = events.length > 0 || users.some(u => u.id !== myProfileDocId);
const result = await bootstrapWallet(walletHasData, createEntityDoc);
// The seeded per-entity docs are appended to their scope indices, which
// `watchShape` subscribes → they enter the reactive reads on the push. No
@@ -1083,8 +1273,11 @@ function useNgData(): FestipodDataContextValue {
// them — otherwise the owner-materializer never opens their inboxes and their
// `participantCount` is never derived.
claimOwnedEventDocs(result.createdDocs.public);
// The seeded PROTECTED docs are deliberately NOT claimed as mine: they are
// FIXTURES (nobody signed in as them), and folding them into the owned-profile
// set is exactly what would make "which profile is mine" ambiguous.
return result;
}, [events.length, users.length, claimOwnedEventDocs]);
}, [events.length, users, myProfileDocId, claimOwnedEventDocs]);
return {
currentUserId, currentUser, currentPrincipal,
+5 -4
View File
@@ -14,10 +14,11 @@
*
* WHAT IT IS NOT — it is NOT the id space the app's own entities live in. A
* UserProfile's id is its document NURI, and a Participation's `fp:user` carries
* that NURI; this principal is a third space. The join between the two is
* explicit and lives in one place (`FestipodDataContext`, where the principal
* selects the current user's profile through `normalizeIdentifier(username)`).
* Never compare this value to an entity id directly.
* that NURI; this principal is a third space, and there is NO join between them.
* A profile is Festipod's own object and this identity says nothing about it:
* **my profile is the profile document I own** (`listMyEntityDocs('protected')`,
* resolved in `FestipodDataContext`). Never compare this value to an entity id,
* and never match it against a profile field to decide who the current user is.
*
* WHY A MODULE STORE and not a React context: `AuthGate` — the component that
* makes the await — is mounted INSIDE `FestipodDataProvider`, so a context it