Code against the polyfill's published contract, and nothing else

The data layer is now reached through one pulled, version-pinned engagement
(`.project/concepts/data-layer/contract_polyfill-surface.md`, @1ecf511e9d).
That copy is the only reference: the provider's sources are never opened, and
what the contract does not answer is a gap raised with it, never worked around
here.

Surface
- `@ng-eventually/sdk` -> `@ng-eventually/polyfill`, one entry point.
- `configure` loses `getSession`, `normalizeId`, `currentUser`; the session
  belongs to the package and its own `init` captures it.
- Placement is named by scope alone -- a session is one user, so the app no
  longer passes an identity it had no way to obtain. This removes a constant
  that made every user collide on one owner's document.
- `init(...)` then `await ensureIdentity()`, in that order, as one sequence:
  React runs child effects first, so the two calls sat in the wrong order and
  the contract now makes that throw.
- `sessionId` relayed as `string | number`, `materialize` -> `read`.

A rejection means "unknown", never "absent"
Four places treated a caught error as an empty result. The worst wrote a
duplicate participation: an unknown count read as zero defeated the idempotence
guard of `joinEvent`. Also fixed: a per-document count, a silently dropped
notification shown optimistically anyway, and a failed listing that left the
owned-event set empty and disabled the materializer for the whole session.

Shared identity is not a Festipod notion
A browser context is one user. The per-scenario identity plant is deleted at
its source and its five sites; what stays is the deployment's wallet file,
which the contract requires an application to serve.

Documentation
The doctrine no longer describes how the data layer works underneath: five
leaves whose subject was internals are gone, a dozen more are re-founded on the
contract's own words, and two frozen arbitrations about a deleted screen were
removed rather than left to mislead a future session.

Test harness
It can sign in at last: cucumber runs under node, which does not load `.env`,
so the harness never received the wallet material and every scenario silently
fell back to an empty local mode. A failed sign-in is now loud on both sides.
The suite also releases what it opens and exits on its own -- runs were still
resident hours after reporting, holding a browser and two servers.

Known red: `@data` cannot be measured. The served wallet accumulates and
nothing resets it; moving the browser profile aside does not, since the data
lives in the wallet file, not the profile.
This commit is contained in:
Sylvain Duchesne
2026-08-16 12:33:14 +02:00
parent 47af46fd09
commit 53c0e095cf
108 changed files with 2741 additions and 3850 deletions
-149
View File
@@ -1,149 +0,0 @@
/**
* AccountContext — the current identity of the stopgap.
*
* STOPGAP (see decision_2026-06-15_shared-wallet-login-flow.md).
*
* The user names their virtual space with an IDENTIFIER at the access barrier
* (AccessGateScreen), in the same act that opens the SHARED wallet — there is no
* separate app login. The identifier is a technical id (a pseudo in practice,
* not a Festipod display name): it is normalized (trimmed, `@`-stripped,
* lowercased) and carried across the broker round-trip.
*
* IDENTIFIER RESOLUTION — the `?id=` URL param is the SOURCE OF TRUTH.
* The shared-wallet flow runs the app in TWO contexts with TWO separate
* localStorage partitions: the top-level page (127.0.0.1) and the broker
* iframe (nextgraph.net) — the browser partitions storage by top-level site,
* so a value written top-level is NOT the value the iframe reads. localStorage
* cannot cross that boundary. But the `@ng-org/web` redirect embeds the FULL
* app URL (query included) in the broker `o=`, which is reloaded in the iframe
* — so a URL param DOES cross. Hence resolution priority:
* (1) `?id=<value>` in the URL — wins whenever present (crosses the frontier);
* (2) else localStorage — same-partition convenience / prefill only.
* When the param is present it also gets persisted to localStorage (same
* partition, convenience) so a plain reload without the param still prefills.
*
* `login()` / `logout()` here only read/write that identifier in localStorage;
* they NEVER call NextGraph (ng.session_stop / wallet_close) — the shared wallet
* stays open underneath. The real logout lives, hidden, in Settings.
*
* The stored value IS the identity id handed to the SDK
* (`setCurrentUser(identifier)`); it is the key the caps and the shim account
* are keyed on. It holds this normalized identifier, not a mixed-case display
* handle.
*
* Default value is non-null so `useAccount()` never throws outside a provider
* (the @ui render harness wraps screens without this provider).
*/
import { createContext, useContext, useState, useCallback, useMemo, useEffect, type ReactNode } from 'react';
// The SDK's framework-agnostic IdentityStore persists the current identity id
// (localStorage-backed). This file keeps the React Context/Provider glue and the
// Festipod identifier handle; `normalizeIdentifier` (the handle → id mapping) is
// the app's own choice. See decision_2026-06-17_eventually-library.
import { accounts } from '@ng-eventually/client';
// Set the current identity on the SDK: the app tells NextGraph WHO is reading, so
// the SDK returns only the data this identity is authorized to see (isolation is
// the SDK's job — see knowledge_trust-model). This is the SDK's "current
// identity" call, not an access rule the app enforces itself.
import { setCurrentUser } from '@ng-eventually/client/polyfill';
// Festipod localStorage key for the current identifier (same-partition
// prefill/convenience only — never the cross-frontier carrier; that's the URL
// param). Renamed to `.identifier` from a historical key that mislabeled this
// account id → any pre-existing stored logins under the old key are dropped
// (acceptable: this is a stopgap test env; the URL param carries identity anyway).
const STORAGE_KEY = 'festipod.account.identifier';
/** Name of the URL param that carries the identifier across the broker frontier. */
const ID_PARAM = 'id';
/** Normalise an identifier handle into the identity id the SDK is given. */
export function normalizeIdentifier(identifier: string | null | undefined): string {
return (identifier ?? '').trim().replace(/^@+/, '').toLowerCase();
}
/** Read the `?id=` URL param (source of truth), normalized. Null when absent. */
function identifierFromUrl(): string | null {
if (typeof window === 'undefined') return null;
try {
const raw = new URLSearchParams(window.location.search).get(ID_PARAM);
if (raw == null) return null;
const norm = normalizeIdentifier(raw);
return norm.length > 0 ? norm : null;
} catch {
return null;
}
}
export interface AccountContextValue {
/** App-level identity (the perceived "login"). null = not connected. */
identifier: string | null;
/** Faux login — persists the identifier. No NextGraph call. */
login: (identifier: string) => void;
/** Faux logout — clears the identifier only. No NextGraph call. */
logout: () => void;
}
/** Browser-safe storage (null in SSR → the store degrades to non-persisting). */
function makeStore(): accounts.IdentityStore {
const ls = typeof window !== 'undefined' ? window.localStorage : null;
return new accounts.IdentityStore(ls, STORAGE_KEY);
}
const AccountContext = createContext<AccountContextValue>({
identifier: null,
login: () => {},
logout: () => {},
});
export function AccountProvider({ children }: { children: ReactNode }) {
const store = useMemo(() => makeStore(), []);
// Resolution priority at init: (1) URL param `?id=` (source of truth, crosses
// the top-level↔iframe frontier), then (2) localStorage (same-partition
// convenience). The URL param wins whenever present.
const [identifier, setIdentifier] = useState<string | null>(() => {
return identifierFromUrl() ?? store.get();
});
// If the URL param is present, it is authoritative: persist it to localStorage
// (same partition, convenience for a subsequent plain reload without the param)
// so the store and the resolved identity agree. Runs once at mount.
useEffect(() => {
const fromUrl = identifierFromUrl();
if (fromUrl && fromUrl !== store.get()) {
store.set(fromUrl);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Tell the SDK who the current identity is, on mount and whenever the account
// changes (login/logout). The SDK uses it to gate reads to what this identity
// may see; the app performs no access check of its own. Normalize the
// identifier handle into the identity id everything else uses.
useEffect(() => {
setCurrentUser(identifier ? normalizeIdentifier(identifier) : null);
}, [identifier]);
const login = useCallback((name: string) => {
// The identifier is normalized (trimmed, `@`-stripped, lowercased) at the
// door, so the stored value IS the identity id — the same key the SDK, the
// caps and the shim account are keyed on. No mixed-case handle to reconcile.
const next = store.set(normalizeIdentifier(name));
if (next) setIdentifier(next);
}, [store]);
const logout = useCallback(() => {
store.clear();
setIdentifier(null);
}, [store]);
return (
<AccountContext.Provider value={{ identifier, login, logout }}>
{children}
</AccountContext.Provider>
);
}
export function useAccount(): AccountContextValue {
return useContext(AccountContext);
}
+212 -165
View File
@@ -8,7 +8,6 @@ import type {
FpNotificationData,
} from '../data/types';
import {
hostInboxNuri,
depositRegistration,
depositLeave,
buildNotification,
@@ -19,8 +18,8 @@ import {
countUserParticipations,
canonicalEventId,
} from '../data/registration';
import { inbox, isNuri } from '@ng-eventually/client';
import type { Nuri } from '@ng-eventually/client';
import { inbox } from '@ng-eventually/polyfill';
import type { Nuri } from '@ng-eventually/polyfill';
import {
CURRENT_USER_ID,
seedEvents,
@@ -30,12 +29,16 @@ import {
seedFriendships,
} from '../data/seedData';
import { useNextGraph } from './NextGraphContext';
import { useAccount, normalizeIdentifier } from './AccountContext';
import { normalizeIdentifier } from '../utils/identifier';
// Relationship is a Festipod concept: the app keeps its own bilateral registry
// and shares its own documents' keys with its neighbours (see shared/utils/connections).
import { declareConnections } from '../utils/connections';
import { listMyEntityDocs, createEntityDoc, resetRegistryCache } from '../utils/storeRegistry';
import { resetCaps } from '@ng-eventually/client/polyfill';
import {
listMyEntityDocs,
createEntityDoc,
openDocumentInbox,
} from '../utils/storeRegistry';
import { useCurrentPrincipal } from '../utils/currentPrincipal';
import { useShapeQuery } from '../data/useShapeQuery';
import { adaptEvents, adaptUsers, adaptParticipations } from '../data/shapeAdapters';
import {
@@ -54,6 +57,13 @@ import { autoSeedEnabled, shouldAutoSeed } from '../utils/autoSeed';
interface FestipodDataContextValue {
currentUserId: string;
currentUser: FpUserData | undefined;
/**
* WHO THE SESSION SIGNED IN AS — the identity `ensureIdentity()` returned,
* known before any document is read. For DISPLAY (and log attribution) only:
* it is a different id space from `currentUserId` (a profile document NURI),
* it is never written into an entity, and no data call takes it.
*/
currentPrincipal: string;
events: FpEventData[];
users: FpUserData[];
@@ -99,15 +109,16 @@ function nextId(prefix: string): string {
return `${prefix}-${++idCounter}`;
}
// The STABLE user-principal prefix. A Participation stores its user (`fp:user`) as
// this principal derived from the login identifier — `urn:festipod:user:<key>` —
// NOT as the UserProfile's `did:ng:` document NURI. `currentUserId` is minted with
// the SAME prefix below, so a participation keyed on it stays consistent with the
// identity the SDK/caps derive. The single source of truth for the prefix, shared
// by the WRITE (currentUserId) and the READ (resolveParticipantUser) so they never
// drift.
// LEGACY, READ ONLY. Participations written by an older version key their user
// (`fp:user`) as `urn:festipod:user:<key>` instead of the UserProfile's `did:ng:`
// document NURI. Nothing MINTS this any more — today's writes carry the profile
// NURI (`currentUserId`) — but `resolveParticipantUser` must still recognize the
// 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). */
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):
@@ -198,7 +209,6 @@ function buildQueries(
// ============================================================================
function useLocalData(empty?: boolean): FestipodDataContextValue {
const { identifier } = useAccount();
const [selectedEventId, setSelectedEventId] = useState<string>(empty ? '' : 'event-1');
const [selectedUserId, setSelectedUserId] = useState<string>('');
@@ -208,15 +218,16 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
const meetingPoints = empty ? [] : seedMeetingPoints;
const friendships = empty ? [] : seedFriendships;
// Resolve current user from the chosen account identifier; fall back to the
// demo default so @ui tests and standalone dev keep working unchanged.
const accountUser = identifier
? users.find(u => normalizeIdentifier(u.username) === normalizeIdentifier(identifier))
: undefined;
const currentUserId = empty ? '' : (accountUser?.id ?? CURRENT_USER_ID);
// Identity-first log prefix: current user id when resolved, else the bare
// `[app][data]` form (e.g. the transient `empty` connecting state).
const logPrefix = currentUserId ? `[${currentUserId}][app][data]` : '[app][data]';
// Demo mode has a single fixture user — the app names no identity of its own.
const currentUserId = empty ? '' : CURRENT_USER_ID;
// Who signed in, when the barrier has settled. Demo mode reads no document, so
// this is the only real identity it can attribute a log line to.
const currentPrincipal = useCurrentPrincipal();
// Identity-first log prefix: the fixture user when there is one, else the
// signed-in principal, else the bare `[app][data]` form.
const logPrefix = currentUserId || currentPrincipal
? `[${currentUserId || currentPrincipal}][app][data]`
: '[app][data]';
const currentUser = users.find(u => u.id === currentUserId);
const selectedEvent = events.find(e => e.id === selectedEventId);
const selectedUser = users.find(u => u.id === selectedUserId);
@@ -256,7 +267,7 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
}, []);
return {
currentUserId, currentUser,
currentUserId, currentUser, currentPrincipal,
events, users, participations, meetingPoints, friendships,
notifications: [],
selectedEventId, setSelectedEventId, selectedEvent,
@@ -273,7 +284,6 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
function useNgData(): FestipodDataContextValue {
const { session } = useNextGraph();
const { identifier } = useAccount();
// The app speaks ONLY in logical scopes — it holds no store id and builds no
// `did:ng:${…}` NURI. It creates ONE document PER ENTITY in its scope
// (`createEntityDoc(scope)`, the SDK create) and READS via the SDK's reactive,
@@ -380,39 +390,15 @@ function useNgData(): FestipodDataContextValue {
const readReady =
eventQuery.isSuccess && userQuery.isSuccess && partQuery.isSuccess;
// IDENTITY SWITCH = FRESH SESSION (isolation). The shared-wallet stopgap keeps
// ONE React tree across a faux logout + re-login under a DIFFERENT identifier (no
// page reload — see AuthGate/AccountContext). `watchShape` re-resolves its scope
// to the new `getCurrentUser()` on the next container/index push, but the
// emulated caps + registry cache and the app-side owned-events set must be reset
// so nothing from the old identity lingers. Ref-guarded so it fires only on a
// real change, not on the first mount.
// Session-local map `${eventId}|${userId}` → the join deposit's uid, so a leave
// in the SAME session can carry `regUid` for a precise cancellation. Absent it
// (cross-session leave), the owner's materializer falls back to (event, user)
// matching — so this is an optimization, not a correctness dependency.
//
// Nothing ever clears this map, and nothing needs to: a browser context is one
// user for its whole life (`ensureIdentity()` answers once, before anything
// renders), so there is never anyone else's leftovers in it.
const joinUidsRef = useRef<Map<string, string>>(new Map());
const prevOwnerRef = useRef<string | null | undefined>(undefined);
useEffect(() => {
if (prevOwnerRef.current === undefined) {
prevOwnerRef.current = identifier;
return;
}
if (prevOwnerRef.current === identifier) return;
prevOwnerRef.current = identifier;
// Fresh session for the new identity: reset the emulated isolation state and
// the owned-events set. `watchShape` re-resolves reads for the new identity on
// its own (scope re-resolution keyed on `getCurrentUser()`).
setOwnedEventIds([]);
joinUidsRef.current.clear();
// Drop the optimistic overlay too: it belongs to the OLD identity's session
// and must not bleed into the new identity's reads (isolation).
setPendingAddEvents([]);
setPendingAddParticipations([]);
setPendingRemoveIds(new Set());
resetCaps();
resetRegistryCache();
}, [identifier]);
// OPTION B — the set of event docs the CURRENT identity OWNS (its own public
// event docs). Each such NURI IS the event `@id` (writeEntity uses the doc NURI
@@ -420,26 +406,65 @@ function useNgData(): FestipodDataContextValue {
// and writes `participantCount` on THAT (owned) doc — never on someone else's.
const [ownedEventIds, setOwnedEventIds] = useState<Nuri[]>([]);
/**
* Fold documents THIS SESSION just created into the owned set.
*
* The backfill below runs once, on connection, so it only ever knows what
* existed at mount. Everything created afterwards has to announce itself, or it
* is owned in fact and unowned as far as this tree is concerned — and the
* owner-materializer, which only ever looks at this set, never runs on it.
*/
const claimOwnedEventDocs = useCallback((created: Nuri[]) => {
if (created.length === 0) return;
setOwnedEventIds(prev => [...new Set([...prev, ...created])]);
}, []);
// Resolve the CURRENT identity's owned public event docs for the materializer
// ONLY (decoupled from the read — `watchShape` resolves reads itself). Bounded to
// the current account (`listMyEntityDocs(owner, 'public')`, NO cross-account
// fan-out). Runs on (re)login to backfill events owned before this mount;
// `createEvent` appends freshly-created events directly. This is NOT a read path
// (it feeds no `events`/`users`/`participations`), only the owner-count derivation.
// this session's own documents (`listMyEntityDocs('public')` — "mine" needs no
// identity, the session is one user's). Runs on connection to backfill events
// owned before this mount; `createEvent` and the seed (`loadTestData` /
// auto-seed) claim what they create through `claimOwnedEventDocs`. This is NOT a
// read path (it feeds no `events`/`users`/`participations`), only the
// owner-count derivation.
//
// A REJECTION HERE MEANS "UNKNOWN", NEVER "THIS SESSION OWNS NOTHING". The two
// are indistinguishable downstream — both leave the set empty — but only one of
// them is true, and taking the wrong one silently disables the owner
// materializer, so every event this session hosts stops converging. So the
// failure is RETRIED, and if it still will not answer, it is said loudly instead
// of leaving a plausible-looking empty set behind.
useEffect(() => {
if (!ready || !identifier) return;
if (!ready) return;
let cancelled = false;
(async () => {
try {
const myPublic = await listMyEntityDocs(identifier, 'public');
if (cancelled) return;
setOwnedEventIds(prev => [...new Set([...prev, ...myPublic])]);
} catch (err) {
console.error(`${logPrefix} owned-events resolution failed:`, err);
for (let attempt = 0; !cancelled; attempt++) {
try {
const myPublic = await listMyEntityDocs('public');
if (cancelled) return;
setOwnedEventIds(prev => [...new Set([...prev, ...myPublic])]);
return;
} catch (err) {
const wait = OWNED_RETRY_BACKOFF_MS[attempt];
if (wait === undefined) {
console.error(
`${logPrefix} owned-events resolution FAILED after ${OWNED_RETRY_BACKOFF_MS.length + 1} ` +
`attempts — the owned set is UNKNOWN, not empty, and events hosted by this session ` +
`will not converge until it is known:`,
err,
);
return;
}
console.warn(
`${logPrefix} owned-events resolution failed (attempt ${attempt + 1}) — retrying in ${wait}ms:`,
err,
);
await new Promise(r => setTimeout(r, wait));
}
}
})();
return () => { cancelled = true; };
}, [ready, identifier]);
}, [ready]);
// Not in SHEX shapes yet
const [meetingPoints, setMeetingPoints] = useState<FpMeetingPointData[]>([]);
@@ -484,35 +509,57 @@ function useNgData(): FestipodDataContextValue {
// Enabled AND synced-empty → a real empty wallet. Seed once.
hasTriedAutoSeed.current = true;
console.log(`${logPrefix} Auto-seed (FESTIPOD_AUTO_SEED): wallet empty (synced), bootstrapping…`);
bootstrapWallet(false, createEntityDoc, identifier || undefined)
bootstrapWallet(false, createEntityDoc)
// The seed's public event docs were created BY THIS SESSION — they are mine.
.then(result => claimOwnedEventDocs(result.createdDocs.public))
.catch(err => console.error(`${logPrefix} Auto-seed failed:`, err));
// 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, identifier]);
}, [ready, readReady, events.length, users.length, claimOwnedEventDocs]);
// --- Derived ---
// Resolve current user from the chosen account identifier (the perceived
// login); fall back to the legacy default while the account layer hydrates.
// 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.
//
// 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.
const currentPrincipal = useCurrentPrincipal();
const currentUser =
(identifier ? users.find(u => normalizeIdentifier(u.username) === normalizeIdentifier(identifier)) : undefined)
(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 current user's PRINCIPAL. When logged in, this is a STABLE
// identifier-derived id (`urn:festipod:user:<normalized-identifier>`) —
// available IMMEDIATELY (no dependency on the protected profile read, which can
// lag) and INVARIANT (it never flips from a fallback to the profile IRI
// mid-session, which would desync a participation written under one value from
// a check under the other). It is the SAME principal the SDK identity
// (`setCurrentUser`) and the cap owner derive from the identifier, so
// participations keyed on it are consistent with reads and isolation. Falls
// back to the read profile's IRI only when there is no login (dev/demo).
const currentUserId =
(identifier ? `${USER_PRINCIPAL_PREFIX}${normalizeIdentifier(identifier)}` : (currentUser?.id || ''));
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
// this render has finished, by which point `logPrefix` is initialized).
const logPrefix = currentUserId ? `[${currentUserId}][app][data]` : '[app][data]';
// this render has finished, by which point `logPrefix` is initialized). Several
// browser contexts can be tailed at once, so a line must say WHOSE it is: the
// profile NURI once it is read, and the signed-in principal before that — which
// is now from the very first render, instead of the anonymous `[app][data]`.
const logPrefix = currentUserId || currentPrincipal
? `[${currentUserId || currentPrincipal}][app][data]`
: '[app][data]';
const selectedEvent = events.find(e => e.id === selectedEventId);
// DISPLAY READ — log participantCount exactly as currently exposed for
// rendering. Compared against the owner-materializer's WRITE logs below, this
@@ -534,11 +581,8 @@ function useNgData(): FestipodDataContextValue {
// writing the event doc: the joiner only deposits; the owner counts.
//
// Reactive, no polling: subscribe the inbox document via `inbox.watch` (a
// `doc_subscribe` push). Today all events share ONE inbox anchor (`hostInboxNuri`
// ignores the eventId → `resolveInboxAnchor()`), so ONE subscription serves all
// my owned events; each push re-materializes every owned event from the full
// deposit list. At per-event-inbox migration this fans to one watch per owned
// event (still one doc each).
// `doc_subscribe` push). One watch PER owned event: each event has its OWN
// inbox, whose address its owner obtains with `openDocumentInbox`.
//
// IDEMPOTENCE / CONVERGENCE: the count is DERIVED from the SET of distinct
// active registrations (`materializeAttendance`: distinct join uids MINUS
@@ -586,9 +630,10 @@ function useNgData(): FestipodDataContextValue {
);
const notifs: FpNotificationData[] = [];
for (const evId of owned) {
// Each event has its OWN inbox document (`documentInbox`), resolved from
// the event doc I own. There is no anchor common to every event any more.
const targetInbox = await hostInboxNuri(evId);
// Each event has its OWN inbox, and only its OWNER can open it. This
// call returns the address the owner reads and watches; a depositor
// never sees it (they name the document instead).
const targetInbox = await openDocumentInbox(evId);
// BEFORE — the event's readable detail (short id + title) and the
// participantCount value as currently READ/exposed (the app-side `events`
// state), captured before this cycle's derive+write. Comparing this to the
@@ -683,7 +728,7 @@ function useNgData(): FestipodDataContextValue {
const unsubscribes: Array<() => void> = [];
(async () => {
for (const evId of owned) {
const targetInbox = await hostInboxNuri(evId);
const targetInbox = await openDocumentInbox(evId);
if (cancelled) return;
unsubscribes.push(inbox.watch(targetInbox, () => void materialize('inbox-push')));
}
@@ -702,18 +747,15 @@ function useNgData(): FestipodDataContextValue {
// the resulting per-document grants. No store id, no document NURI crosses here.
useEffect(() => {
if (!ready || !currentUserId) return;
// Connection ids must be the SAME key space as the cap owners: each doc is
// opened with `normalizeIdentifier(owner)`, and the reader identity is set via
// `setCurrentUser(normalizeIdentifier(identifier))`. The app models
// friendships with user IRIs, so map each peer IRI → its id key before
// declaring, and assert AS the current user's id key. Peers with no known id
// are skipped (can't be keyed). This is what makes "protected = my bilateral
// connections" actually discriminate in @data.
// `inbox.share(doc, toUser)` names a PERSON, so the app models both sides of
// a relationship in ONE key space: the normalized profile username. It maps
// each peer IRI → that key before declaring, and asserts AS its own key.
// Peers with no known profile are skipped (they cannot be named).
const idKeyOf = (userIri: string): string | undefined => {
const u = users.find(x => x.id === userIri);
return u?.username ? normalizeIdentifier(u.username) : undefined;
};
const selfKey = identifier ? normalizeIdentifier(identifier) : idKeyOf(currentUserId);
const selfKey = idKeyOf(currentUserId);
if (!selfKey) return;
const myPeers = friendships
.filter(f => f.userId === currentUserId || f.friendId === currentUserId)
@@ -724,7 +766,7 @@ function useNgData(): FestipodDataContextValue {
// fire-and-forget from this effect — nothing downstream waits on it.
void declareConnections(myPeers, selfKey)
.catch(err => console.error(`${logPrefix} declareConnections failed:`, err));
}, [ready, friendships, currentUserId, users, identifier]);
}, [ready, friendships, currentUserId, users]);
const queries = buildQueries(
events, users, participations, meetingPoints, friendships, currentUserId,
@@ -744,18 +786,23 @@ function useNgData(): FestipodDataContextValue {
const createEvent = useCallback(async (event: Omit<FpEventData, 'id'>): Promise<FpEventData> => {
console.log(`${logPrefix} createEvent (NG):`, event.title);
// Owner principal = the account identifier (what setCurrentUser declares). The
// SDK create returns THIS entity's OWN public document and declares its
// ReadCap policy (public → world-readable). Fall back to a generic account
// label when no login is present (dev/demo).
const owner = identifier || currentUserId || 'anon';
// The SDK create returns THIS entity's OWN public document and declares its
// access policy (public → world-readable). Placement is named by SCOPE
// alone: the session belongs to one user, so there is no owner to name.
// A creation that cannot be recorded THROWS — it never hands back a
// reference that would read empty forever — so this rejects the whole
// `createEvent` rather than returning a half-made event.
// Create the event's OWN document in the PUBLIC scope (one doc per entity),
// then WRITE the event RDF DIRECTLY into that document (writeEntity) — not via
// the scope-coupled `ngSet.add`, which can't write into a not-yet-subscribed
// per-entity doc against the real broker. The doc's NURI is appended to the
// public scope index, which `watchShape('public')` subscribes → the event
// enters the reactive read on the push. The written subject IRI is the `@id`.
const eventGraph = await createEntityDoc(owner, 'public');
const eventGraph = await createEntityDoc('public');
// An event is a document people SIGN UP TO, so its owner opens its inbox
// here, at creation. A document only HAS one if its owner opened it; without
// this, a registrant's deposit would have nothing to reach.
await openDocumentInbox(eventGraph);
const eventId = await writeEntity(eventGraph, ENTITY_TYPE.event, {
title: str(event.title), description: str(event.description), date: str(event.date),
location: str(event.location), distance: flt(event.distance),
@@ -793,7 +840,7 @@ function useNgData(): FestipodDataContextValue {
// work. Until it lands, a created event is reachable by its creator only.
}
return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` };
}, [currentUserId, identifier]);
}, [currentUserId]);
const updateEvent = useCallback(async (id: string, updates: Partial<FpEventData>) => {
console.log(`${logPrefix} updateEvent (NG):`, id, updates);
@@ -801,13 +848,8 @@ function useNgData(): FestipodDataContextValue {
// it is both the write graph and the subject. Persist each provided mutable
// field DIRECTLY via SPARQL (the durable write); `watchShape` re-reads on the
// resulting broker push (the doc is already subscribed) — no manual re-query.
// `id` reaches us as a plain domain string (it was read back off a document),
// so narrow it here — the boundary where an untyped string becomes a NURI.
// Anything that is not one names no document and cannot be written to.
if (!isNuri(id)) {
console.error(`${logPrefix} updateEvent: "${id}" is not a document NURI — nothing to write.`);
return;
}
// No pre-flight NURI guard: every SDK entry takes the reference as it stands
// and validates at its own door.
const graph = id;
const persists: Promise<void>[] = [];
if (updates.participantCount !== undefined) {
@@ -837,18 +879,24 @@ function useNgData(): FestipodDataContextValue {
// The reactive participation set can lag a just-written participation, so a
// second join checking only the set would write a DUPLICATE (breaking "exactly
// one participation"). The broker query sees the real state regardless of lag.
const already = await countUserParticipations(identifier || uid || 'anon', eventId, uid).catch(() => 0);
//
// A FAILED count is UNKNOWN, not zero, so it is NOT caught here: reading it as
// "not participating yet" is precisely how a duplicate gets written. The
// rejection propagates out of `joinEvent`, and the screen that called it says
// the sign-up could not be recorded — which is the truth.
const already = await countUserParticipations(eventId, uid);
if (already > 0) {
console.log(`${logPrefix} Already participating (broker-confirmed), skipping`);
return;
}
// 1) Persist the Participation as its OWN document in the PROTECTED scope
// (one doc per entity). Owner = the account identifier (setCurrentUser key).
// Its NURI is appended to the protected scope index, which
// `watchShape('protected')` subscribes → the participation enters the
// reactive read on the push.
const owner = identifier || uid || 'anon';
const partGraph = await createEntityDoc(owner, 'protected');
// (one doc per entity). Its NURI is appended to the protected scope index,
// which `watchShape('protected')` subscribes → the participation enters
// the reactive read on the push.
// A creation that cannot be recorded THROWS, so a failure here rejects
// `joinEvent` instead of depositing a registration for a participation
// document that would read empty forever.
const partGraph = await createEntityDoc('protected');
// WRITE the participation RDF DIRECTLY into its own document (writeEntity) —
// not via the scope-coupled `ngSet.add` (can't write a not-yet-subscribed
// per-entity doc against the real broker). The written subject is the
@@ -883,14 +931,8 @@ function useNgData(): FestipodDataContextValue {
// 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 that document is what carries
// the inbox. `eventId` arrives as a plain string (a caller's argument), so
// narrow it here — the boundary. Throwing lands in this block's own catch,
// which is already the "deposit is best-effort" contract.
if (!isNuri(eventId)) {
throw new Error(`event id "${eventId}" is not a document NURI — no event inbox to deposit into`);
}
const targetInbox = await hostInboxNuri(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)
// could read it in clear; the count itself does not depend on reading it.
console.log(
@@ -898,15 +940,18 @@ 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(targetInbox, eventId, registrantId, partGraph);
const { ts, 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). Best-effort — the inbox materialization is the source of
// truth; this direct write only pre-warms the reactive read.
const notifGraph = await createEntityDoc(owner, 'protected');
await insertNotification(notifGraph, notif).catch(() => { /* data-level best-effort */ });
// 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.
@@ -914,7 +959,7 @@ function useNgData(): FestipodDataContextValue {
} catch (err) {
console.error(`${logPrefix} joinEvent inbox/notify failed:`, err);
}
}, [events, currentUserId, identifier]);
}, [events, currentUserId]);
const leaveEvent = useCallback(async (eventId: string, userId?: string) => {
const uid = userId || currentUserId;
@@ -929,13 +974,7 @@ function useNgData(): FestipodDataContextValue {
// removes the Participation server-side so it does NOT resurrect after re-sync.
// The delete targets the participation's own document (part.id) and is
// identified by its OWN subject IRI (part.id), not a string-match on object IRIs.
// `part.id` was read back off a document, so narrow it here (the boundary) —
// a value that names no document cannot be the delete's anchor.
const graphNuri = part.id;
if (!isNuri(graphNuri)) {
console.error(`${logPrefix} leaveEvent: participation id "${graphNuri}" is not a document NURI — refusing to delete.`);
return;
}
const subjectIri = part.id;
let result;
try {
@@ -979,12 +1018,8 @@ function useNgData(): FestipodDataContextValue {
// is derived from the SET of distinct active registrations, not from −1).
try {
const registrantId = uid || null;
// Same boundary as `joinEvent`: narrow the event id before resolving the
// document's inbox; the throw lands in this block's own best-effort catch.
if (!isNuri(eventId)) {
throw new Error(`event id "${eventId}" is not a document NURI — no event inbox to deposit into`);
}
const targetInbox = await hostInboxNuri(eventId);
// Same as `joinEvent`: the leave marker NAMES the event document; no inbox
// address is resolved on the depositor's side.
// Carry the join uid when this session minted it (precise cancellation);
// otherwise the owner falls back to (eventId, userId) matching.
const regUid = joinUidsRef.current.get(`${eventId}|${uid}`);
@@ -992,7 +1027,7 @@ function useNgData(): FestipodDataContextValue {
`${logPrefix} leaveEvent — depositing participation leave marker into event inbox: ` +
`event=${canonicalEventId(eventId)} user=${uid} regUid=${regUid ?? '(none)'}`,
);
await depositLeave(targetInbox, eventId, registrantId, regUid);
await depositLeave(eventId, registrantId, regUid);
joinUidsRef.current.delete(`${eventId}|${uid}`);
} catch (err) {
console.error(`${logPrefix} leaveEvent inbox deposit failed:`, err);
@@ -1001,7 +1036,7 @@ function useNgData(): FestipodDataContextValue {
// DELETE pushes → the reactive read drops it (`isParticipating` reflects it).
// The count itself follows the owner's materialization of the leave marker
// (reactive, cross-session).
}, [participations, events, currentUserId, identifier]);
}, [participations, events, currentUserId]);
const addMeetingPoint = useCallback((mp: Omit<FpMeetingPointData, 'id'>) => {
setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]);
@@ -1022,13 +1057,7 @@ function useNgData(): FestipodDataContextValue {
// The current user's profile is its own document (subject IRI = doc NURI).
const target = currentUser ?? users[0];
if (!target) return;
// Same boundary as `updateEvent`: the profile `@id` comes back as a plain
// string from the read, so narrow it before it is used as a write target.
const graph = target.id;
if (!isNuri(graph)) {
console.error(`${logPrefix} updateProfile: "${graph}" is not a document NURI — nothing to write.`);
return;
}
const persists: Promise<void>[] = [];
if (updates.name !== undefined) persists.push(updateEntityField(graph, graph, 'name', str(updates.name)));
if (updates.initials !== undefined) persists.push(updateEntityField(graph, graph, 'initials', str(updates.initials)));
@@ -1045,15 +1074,20 @@ function useNgData(): FestipodDataContextValue {
// 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;
const result = await bootstrapWallet(walletHasData, createEntityDoc, identifier || undefined);
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
// manual registration / re-list.
// OWNERSHIP is a different question from READING, and it is not answered by
// the push: these public event docs were created by THIS session, so claim
// them — otherwise the owner-materializer never opens their inboxes and their
// `participantCount` is never derived.
claimOwnedEventDocs(result.createdDocs.public);
return result;
}, [events.length, users.length, identifier]);
}, [events.length, users.length, claimOwnedEventDocs]);
return {
currentUserId, currentUser,
currentUserId, currentUser, currentPrincipal,
events, users,
participations,
meetingPoints,
@@ -1083,15 +1117,28 @@ function NgDataProvider({ children }: { children: ReactNode }) {
export function FestipodDataProvider({ children }: { children: ReactNode }) {
const { status } = useNextGraph();
// No identity resolved at this level (only NG connection status is known here) —
// identity-first prefix falls back to the bare `[app][data]` form.
console.log('[app][data] Provider — NG status:', status);
// SIGNING IN MUST HAVE SETTLED BEFORE THE DATA PROVIDER STARTS. Every call the
// NG provider makes addresses "MY documents" — placement by scope
// (`createEntityDoc` / `listMyEntityDocs`) and the reactive scope reads alike —
// and there is no "my" until `ensureIdentity()` has answered. Being CONNECTED is
// not being SIGNED IN: the session opens first, the identity settles after. A
// provider mounted in between fires its reads and its owned-documents
// resolution against no identity — they fail once, at mount, and the memoized
// observables never retry, so the screens stay empty for the whole session
// while the writes that come later succeed.
//
// The order is carried HERE, by which provider is mounted, rather than by a
// guard repeated in every effect — the invariant cannot then be forgotten by
// the next call site.
const signedIn = useCurrentPrincipal() !== '';
console.log('[app][data] Provider — NG status:', status, '| signed in:', signedIn);
if (status === 'connected') {
if (status === 'connected' && signedIn) {
return <NgDataProvider>{children}</NgDataProvider>;
}
if (status === 'connecting') {
// NG initializing: show empty state (no misleading seed data flash)
if (status === 'connecting' || status === 'connected') {
// NG initializing, or connected but not signed in yet: show empty state (no
// misleading seed data flash, and no read issued before there is a "my").
return <LocalDataProvider empty>{children}</LocalDataProvider>;
}
// Disconnected or error: demo mode with seed data
+36 -55
View File
@@ -1,5 +1,5 @@
import React, { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
import { session, sessionPromise, init as initNg, type NextGraphSession } from '../utils/ngSession';
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
import { session, sessionPromise, startNgSession, type NextGraphSession } from '../utils/ngSession';
type NgStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
@@ -7,17 +7,15 @@ interface NextGraphContextValue {
status: NgStatus;
session: NextGraphSession | undefined;
error: string | undefined;
connect: () => void;
}
const NextGraphContext = createContext<NextGraphContextValue>({
status: 'disconnected',
session: undefined,
error: undefined,
connect: () => {},
});
// Track whether initNg() has been called (module-level to survive re-renders)
// Track whether the session start has been triggered (module-level to survive re-renders)
let ngInitStarted = false;
// Detect if we're running inside the NG broker iframe
@@ -28,22 +26,37 @@ export function NextGraphProvider({ children }: { children: ReactNode }) {
const [ngSession, setNgSession] = useState<NextGraphSession | undefined>(session);
const [error, setError] = useState<string | undefined>();
// Auto-init ONLY when running inside the broker iframe.
// Outside the broker, initNgWeb() would redirect the page — wait for explicit connect().
useEffect(() => {
if (!isInsideBroker || ngInitStarted) return;
// Start the session ALWAYS, inside the broker iframe and standalone alike.
//
// It used to be iframe-only, because outside the broker starting the session
// REDIRECTS the page and that had to stay a deliberate act — the user clicked
// "Entrer" on the app's own access screen. That screen is gone: signing in is
// `ensureIdentity()`, and the SDK shows whatever a user must see. Nothing is
// left to click, so the redirect must fire on its own.
//
// And it is no longer optional: a session arrives ONLY through the SDK's `init`,
// and `ensureIdentity()` awaited before that call has been made throws. An
// unconditional start is what the contract asks for, in every context.
//
// `startNgSession` is itself idempotent (AuthGate calls it too, right before its
// await, so the order does not depend on React's effect ordering). What this
// provider adds on top is the CONNECTION STATUS the app displays.
const startSession = useCallback(() => {
if (ngInitStarted) return;
ngInitStarted = true;
console.log('[NG] Inside broker iframe — auto-init');
console.log(`[NG] session start (${isInsideBroker ? 'broker iframe' : 'standalone → redirect'})`);
setStatus('connecting');
initNg();
setError(undefined);
startNgSession();
sessionPromise
.then((s) => {
// The session (incl. native store ids) is handed to the SDK at the
// sanctioned injection point (ngSession/storeRegistry). The app context
// itself does not surface or manipulate store ids — it only tracks the
// connection status and the opaque session handle.
// Nothing is handed to the SDK here: the session is the SDK's, captured by
// its own `init`, and no call takes one. What this context keeps is the
// opaque handle the app reads a `session_id` off for the `docs`
// primitives, plus the connection status the screens display. No store id
// crosses this boundary — placement is named by scope alone.
console.log('[NG] Session obtained — connected');
setNgSession(s);
setStatus('connected');
@@ -55,49 +68,17 @@ export function NextGraphProvider({ children }: { children: ReactNode }) {
});
}, []);
// Un-stick the gate after a broker redirect that didn't complete (e.g. "no
// wallet": the user imports in another tab, comes back via the back button).
// The standalone page is restored from bfcache with status frozen on
// 'connecting' → "Entrer" stays disabled. Reset it so they can retry.
useEffect(() => {
if (isInsideBroker) return;
const onPageShow = (e: PageTransitionEvent) => {
if (e.persisted && !session) {
ngInitStarted = false;
setStatus('disconnected');
setError(undefined);
}
};
window.addEventListener('pageshow', onPageShow);
return () => window.removeEventListener('pageshow', onPageShow);
}, []);
useEffect(() => { startSession(); }, [startSession]);
// connect(): called by the user clicking "Se connecter".
// When outside the broker, initNgWeb() will redirect to the broker.
const connect = useCallback(() => {
if (status === 'connecting' || status === 'connected') return;
console.log('[NG] connect() called, current status:', status);
setStatus('connecting');
setError(undefined);
ngInitStarted = true;
initNg();
sessionPromise
.then((s) => {
setNgSession(s);
setStatus('connected');
})
.catch((err) => {
console.error('[NG] Connection failed:', err);
setError(err?.message || 'Connexion NextGraph impossible');
setStatus('error');
});
}, [status]);
// NO retry on the return from the broker round-trip. That journey belongs to the
// SDK: coming back to the page finds its barrier live again and prefilled, and
// confirming it hands the page over a second time — our page is never reloaded
// and nothing outside the barrier is touched. A `pageshow` handler of ours that
// re-started the session would be a second actor driving the same journey, and
// it would redirect out from under the barrier the SDK just put up.
return (
<NextGraphContext.Provider value={{ status, session: ngSession, error, connect }}>
<NextGraphContext.Provider value={{ status, session: ngSession, error }}>
{children}
</NextGraphContext.Provider>
);
+13 -12
View File
@@ -28,9 +28,10 @@
* round-trip through the shape.
*/
import { docs, escapeLiteral, assertNuri } from '@ng-eventually/client';
import type { Nuri } from '@ng-eventually/client';
import { docs } from '@ng-eventually/polyfill';
import type { Nuri, NuriLike } from '@ng-eventually/polyfill';
import { sessionPromise } from '../utils/ngSession';
import { escapeLiteral, escapeIri } from './sparqlEscape';
/** The RDF `@type` IRIs of the Festipod entities written per-document. */
export const ENTITY_TYPE = {
@@ -70,9 +71,10 @@ function renderTerm(t: EntityTerm): string | null {
case 'boolean':
return `"${t.value ? 'true' : 'false'}"^^<${XSD}boolean>`;
case 'iri':
// The reference IRIs are trusted-shaped NURIs (entity subject IRIs coming
// back from a prior write / the ORM) → validate as a NURI, embed as `<…>`.
return `<${assertNuri(String(t.value))}>`;
// The reference IRIs are entity subject IRIs (coming back from a prior
// write / the ORM). No pre-flight guard: the SDK validates at its own
// door, and here the value only has to be a well-formed IRIREF body.
return `<${escapeIri(String(t.value))}>`;
}
}
@@ -85,22 +87,21 @@ function renderTerm(t: EntityTerm): string | null {
* the re-read consistent. `subject` is the entity `@id` (= its document NURI).
*/
export async function updateEntityField(
graphNuri: Nuri,
graphNuri: NuriLike,
subject: string,
field: string,
term: EntityTerm,
): Promise<void> {
const sid = (await sessionPromise).session_id;
const s = assertNuri(subject);
const s = escapeIri(subject);
const pred = `${FP}${field}`;
const obj = renderTerm(term);
// NO explicit `GRAPH <…>` wrapper: anchored to `graphNuri`, both the DELETE and
// the INSERT target that doc's anchored DEFAULT graph — the exact graph the
// anchored read queries. This no-GRAPH default-graph form is the CANONICAL SDK
// write shape (same as writeEntity / registration.ts); SDK graph details live in
// `@ng-eventually/client`, not here. `assertNuri(graphNuri)` is done implicitly
// by `docs.sparqlUpdate`'s anchor handling — validate `subject` here as it lands
// in an IRI position.
// `@ng-eventually/polyfill`, not here. `docs.sparqlUpdate` validates the anchor at
// its own door — `subject` only needs escaping, as it lands in an IRI position.
const del = `DELETE WHERE { <${s}> <${pred}> ?o }`;
await docs.sparqlUpdate(sid, del, graphNuri);
if (obj !== null) {
@@ -137,10 +138,10 @@ export async function writeEntity(
// that doc's anchored DEFAULT graph — the exact graph the anchored read queries.
// This is the CANONICAL SDK write shape (anchor scopes the write, no GRAPH clause;
// same as updateEntityField / registration.ts); SDK graph details live in
// `@ng-eventually/client`, not here.
// `@ng-eventually/polyfill`, not here.
const update = `
INSERT DATA {
<${assertNuri(subject)}> ${triples.join(' ;\n ')} .
<${escapeIri(subject)}> ${triples.join(' ;\n ')} .
}`;
await docs.sparqlUpdate(sid, update, graphNuri);
return subject;
File diff suppressed because one or more lines are too long
+41 -52
View File
@@ -1,12 +1,11 @@
/**
* Registration domain glue — the FESTIPOD interpretation layered on top of the
* GENERIC `@ng-eventually/client` `inbox` mechanism (T02.b) and the low-level
* GENERIC `@ng-eventually/polyfill` `inbox` mechanism (T02.b) and the low-level
* `docs` SPARQL primitives.
*
* The lib stays domain-agnostic: it knows only "deposit an opaque payload into
* an inbox document NURI" and "run a SPARQL update against the real injected
* ng". THIS module supplies the Festipod domain:
* - how to derive a meeting-point / host inbox NURI (`hostInboxNuri`),
* The SDK stays domain-agnostic: it knows only "deposit an opaque payload for a
* document" and "run a SPARQL update against the real injected ng". THIS module
* supplies the Festipod domain:
* - the shape of the deposit payload (`RegistrationPayload`),
* - how a deposit becomes a host-facing `FpNotification` (`buildNotification`),
* - the SPARQL DELETE-WHERE that DURABLY removes a Participation server-side
@@ -14,13 +13,14 @@
* bug (see caveat_participation-deletion).
*
* Importable by `shared/` and by domain modules (meeting/notification) — it never
* imports a module, only the lib. See T02.a (shapes) / T02.b (inbox).
* imports a module, only the SDK. See T02.a (shapes) / T02.b (inbox).
*/
import { inbox, docs, escapeLiteral, escapeIri, assertNuri } from '@ng-eventually/client';
import type { Nuri } from '@ng-eventually/client';
import { inbox, docs } from '@ng-eventually/polyfill';
import type { Nuri, NuriLike } from '@ng-eventually/polyfill';
import { sessionPromise } from '../utils/ngSession';
import { documentInbox, listMyEntityDocs } from '../utils/storeRegistry';
import { listMyEntityDocs } from '../utils/storeRegistry';
import { escapeLiteral, escapeIri } from './sparqlEscape';
import type { FpNotificationData } from './types';
/** Notification IRI/type constants (mirror the SHEX Notification shape). */
@@ -113,25 +113,6 @@ export function canonicalEventId(id: string): string {
return i === -1 ? id : id.slice(0, i);
}
/**
* Resolve the inbox document NURI for a meeting point / host.
*
* Preference order: the explicit MeetingPoint `inbox` NURI (SHEX field, T02.a)
* when known → else THE EVENT DOCUMENT'S OWN INBOX (`documentInbox(eventDoc)`).
*
* An inbox BELONGS to someone — there is no inbox common to every identity — so
* the deposit target is the inbox of the document the deposit is ABOUT. `eventId`
* is the event's document NURI (one document per entity: the entity's `@id` IS
* its document), which is exactly what `documentInbox` takes. The owner reads it
* back at its next connection; a depositor that is not the owner must have been
* GIVEN the inbox NURI — that is `explicitInbox` (the MeetingPoint `fp:inbox`
* field), the only cross-identity path.
*/
export async function hostInboxNuri(eventId: Nuri, explicitInbox?: Nuri): Promise<Nuri> {
if (explicitInbox) return explicitInbox;
return documentInbox(eventId);
}
/**
* Build the host-facing notification from a registration deposit. The recipient
* is the event host; `ref` points at the event; the payload carries the raw
@@ -154,9 +135,12 @@ export function buildNotification(
}
/**
* Deposit a registration into the host's inbox (generic lib `inbox.post`) +
* Deposit a registration FOR THE EVENT DOCUMENT (`inbox.postToDocument`) +
* return the deposit ts so the caller can mint a matching notification.
*
* A deposit names the DOCUMENT it is about, never an address: the depositor
* holds no inbox address and needs none. Only the event's owner reads it back.
*
* The registrant identity travels in the PAYLOAD (`userId`), which the host
* materializer reads. The transport-level `from` is left ANONYMOUS (`null`): the
* SDK binds `from` to the depositor's own identity and rejects a mismatched one
@@ -164,13 +148,15 @@ export function buildNotification(
* domain identity belongs in the payload, not in the transport `from`.
*/
export async function depositRegistration(
targetInbox: Nuri,
eventId: string,
eventDoc: NuriLike,
registrantId: string | null,
participationDoc?: string,
): Promise<{ ts: number; uid: string }> {
const ts = Date.now();
const uid = mintDepositUid();
// One document per entity: the event's `@id` IS its document, so the deposit
// target and the payload's event key are the same value.
const eventId: string = eventDoc;
const payload: RegistrationPayload = {
kind: NOTIF_TYPE_NEW_PARTICIPANT,
eventId,
@@ -178,7 +164,7 @@ export async function depositRegistration(
uid,
participationDoc,
};
await inbox.post(targetInbox, { from: null, payload, ts });
await inbox.postToDocument(eventDoc, { from: null, payload, ts });
console.log(
`[Attendance] deposit new-participant → inbox for event=${canonicalEventId(eventId)} ` +
`user=${registrantId ?? '(anon)'} uid=${uid}`,
@@ -187,7 +173,7 @@ export async function depositRegistration(
}
/**
* Deposit a LEAVE marker into the event's inbox (Option B, symmetric to
* Deposit a LEAVE marker FOR THE EVENT DOCUMENT (Option B, symmetric to
* `depositRegistration`). The owner materializes it to remove the matching
* registration from the active set. `regUid` (the join deposit's uid) lets the
* owner cancel exactly that registration; when unknown, the owner falls back to
@@ -195,13 +181,13 @@ export async function depositRegistration(
* re-synced leave removes an already-removed registration → no double-decrement.
*/
export async function depositLeave(
targetInbox: Nuri,
eventId: string,
eventDoc: NuriLike,
registrantId: string | null,
regUid?: string,
): Promise<{ ts: number; uid: string }> {
const ts = Date.now();
const uid = mintDepositUid();
const eventId: string = eventDoc;
const payload: RegistrationPayload = {
kind: NOTIF_TYPE_LEAVE_PARTICIPANT,
eventId,
@@ -209,7 +195,7 @@ export async function depositLeave(
uid,
regUid,
};
await inbox.post(targetInbox, { from: null, payload, ts });
await inbox.postToDocument(eventDoc, { from: null, payload, ts });
console.log(
`[Attendance] deposit leave-participant → inbox for event=${canonicalEventId(eventId)} ` +
`user=${registrantId ?? '(anon)'} uid=${uid} regUid=${regUid ?? '(none)'}`,
@@ -347,20 +333,23 @@ export async function readRegistrationNotifications(
* just-written participation, so a second join checking only the reactive set would
* write a duplicate. Querying the broker sees the real state regardless of read lag.
*
* Scoped to the CURRENT account (`identifier`) via `listMyEntityDocs` — a user's own
* participations live in their own account, so there is NO need to fan out over all
* accounts (which would open/sync other accounts' unsynced docs → the ~75s hang).
* Scoped to MY OWN protected documents via `listMyEntityDocs` — a user's own
* participations live in their own documents, so there is NO need to fan out over
* anyone else's (which would open/sync unsynced docs → the ~75s hang).
*/
export async function countUserParticipations(
identifier: string,
eventId: string,
userId: string,
): Promise<number> {
const sid = (await sessionPromise).session_id;
const docs_ = await listMyEntityDocs(identifier, 'protected');
const docs_ = await listMyEntityDocs('protected');
let total = 0;
for (const g of docs_) {
total += await countParticipations(sid, g, eventId, userId).catch(() => 0);
// NO `.catch(() => 0)` here. A failed count means UNKNOWN, never zero: this
// number decides whether a participation already exists, and answering "none"
// when we could not find out is what writes a duplicate. The rejection travels
// to the caller, which surfaces it.
total += await countParticipations(sid, g, eventId, userId);
}
return total;
}
@@ -387,8 +376,8 @@ export interface DeleteParticipationResult {
* serialized these fields — this is a COUNT (read-only), so tolerance here is
* safe (unlike a DELETE, it can never over-remove). */
async function countParticipations(
sid: string,
graphNuri: Nuri,
sid: string | number,
graphNuri: NuriLike,
eventId: string,
userId: string,
): Promise<number> {
@@ -398,7 +387,7 @@ async function countParticipations(
// into the anchored DEFAULT graph (one doc per entity), so this count reads that
// same anchored default graph — anchored to `graphNuri`, no `GRAPH` clause. This
// is the CANONICAL SDK read/write shape (write and read the same anchored default
// graph); SDK graph details live in `@ng-eventually/client`, not here.
// graph); SDK graph details live in `@ng-eventually/polyfill`, not here.
const query = `
SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE {
?s a <${P.partType}> ;
@@ -446,7 +435,7 @@ async function countParticipations(
* state for the immediate UI, AND only once `remaining === 0`.
*/
export async function deleteParticipation(
graphNuri: Nuri,
graphNuri: NuriLike,
eventId: string,
userId: string,
subjectIri?: string,
@@ -487,7 +476,7 @@ export async function deleteParticipation(
// graph — anchored to `graphNuri`, no `GRAPH` clause. Write, read and delete all
// use the one CANONICAL anchored-default-graph shape so they stay consistent (a
// mismatched target here would no-op the delete → the F2 resurrection). SDK graph
// details live in `@ng-eventually/client`, not here.
// details live in `@ng-eventually/polyfill`, not here.
const sweep = `
DELETE { ?s ?p ?o }
WHERE {
@@ -506,8 +495,8 @@ export async function deleteParticipation(
// known. This catches the residual case where an object-form drift makes the
// (event, user) sweep miss a subject we nonetheless hold the id for — exact,
// bound as an IRI, cannot no-op on drift.
if (hasSubject) {
const s = assertNuri(subjectIri!);
if (subjectIri && hasSubject) {
const s = escapeIri(subjectIri);
// Anchored default-graph (no `GRAPH` clause), like the sweep above.
const bySubject = `
DELETE { <${s}> ?p ?o }
@@ -535,7 +524,7 @@ export async function insertNotification(
// recipient/ref are bare domain ids ("user-1", "event-1"), not absolute IRIs;
// store them as string literals to keep the INSERT valid (the raw shape read
// is not the primary surfacing path — the inbox read is). Every literal is
// escaped via the lib's escapeLiteral (guards \ " \n \r \t — SPARQL injection).
// escaped via the app's escapeLiteral (guards \ " \n \r \t — SPARQL injection).
const refTriple = notif.ref ? `\n <${P.ref}> "${escapeLiteral(notif.ref)}" ;` : '';
const payloadTriple = notif.payload
? `\n <${P.payload}> "${escapeLiteral(notif.payload)}" ;`
@@ -543,10 +532,10 @@ export async function insertNotification(
// NO explicit `GRAPH <…>` wrapper: anchored to `graphNuri`, the INSERT lands in
// that doc's anchored DEFAULT graph — the CANONICAL SDK write shape, consistent
// with every other per-entity write (writeEntity / updateEntityField). SDK graph
// details live in `@ng-eventually/client`, not here.
// details live in `@ng-eventually/polyfill`, not here.
const update = `
INSERT DATA {
<${assertNuri(subject)}> a <${NOTIF_TYPE_IRI}> ;
<${escapeIri(subject)}> a <${NOTIF_TYPE_IRI}> ;
<${P.recipient}> "${escapeLiteral(notif.recipientId)}" ;
<${P.type}> "${escapeLiteral(notif.type)}" ;${refTriple}${payloadTriple}
<${P.timestamp}> "${escapeLiteral(notif.timestamp)}" ;
+1 -1
View File
@@ -12,7 +12,7 @@
* mapping remains, extracted here.
*/
import type { UnionSubject } from '@ng-eventually/client';
import type { UnionSubject } from '@ng-eventually/polyfill';
import type { FpEventData, FpUserData, FpParticipationData } from './types';
const FP = 'http://festipod.org/';
+36
View File
@@ -0,0 +1,36 @@
/**
* SPARQL escaping for the app's own raw-query paths — the ONE place Festipod
* escapes a value it splices into a SPARQL string.
*
* The SDK publishes no escaper: every SDK entry takes a value and validates it
* at the door. But `docs.sparqlQuery` / `docs.sparqlUpdate` take a query STRING
* the app builds itself (entityWrites, registration), so the app owns the
* escaping of anything it interpolates into that string. Kept minimal and used
* from both call sites rather than duplicated.
*/
/**
* Escape a value landing inside a `"…"` SPARQL literal: backslash and quote
* first (so the added escapes are not re-escaped), then the control characters
* that would otherwise terminate the literal.
*/
export function escapeLiteral(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t');
}
/**
* Escape a value landing inside a `<…>` SPARQL IRIREF: percent-encode every
* character an IRIREF may not contain (the delimiters, plus the C0 controls and
* space). A well-formed document reference passes through unchanged.
*/
export function escapeIri(value: string): string {
return value.replace(
/[<>"{}|^`\\\u0000-\u0020]/g,
c => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`,
);
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -25,7 +25,7 @@
*/
import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import { watchShape, type ShapeQuery, type ShapeObservable, type UnionSubject } from '@ng-eventually/client';
import { watchShape, type ShapeQuery, type ShapeObservable, type UnionSubject } from '@ng-eventually/polyfill';
import { beginQuery, resolveQuery } from './pendingQueries';
import { recordSet, shapeLabel, totalsSummary, totalSets } from './dataStats';
-22
View File
@@ -43,8 +43,6 @@ export interface BrowserPool {
* capture failed / mock mode.
*/
sharedWalletState: Awaited<ReturnType<BrowserContext['storageState']>> | null;
/** Password of the e2e shared wallet file (festipod-e2e-tests) — for assertions. */
sharedWalletPassword: string;
/**
* Navigate a page through the NG broker to load `appUrl` in its iframe and
* return the app's Frame. Set by hooks.ts (closes over the broker login flow).
@@ -52,19 +50,6 @@ export interface BrowserPool {
setupBrokerPage: (page: Page, appUrl: string) => Promise<Frame>;
/** Finish the broker login once the page is already on the broker (post-redirect). */
completeBrokerLogin: (page: Page, appUrl: string, walletPassword?: string) => Promise<Frame>;
/**
* Drive the standalone nextgraph.eu "Import a Wallet File" flow on `page`:
* upload the .ngw file, unlock with `password`. The wallet FILE is the static,
* reusable assisted-import primitive (a TextCode is a transient 5-min transfer,
* unusable to embed). After this the page's context holds the wallet.
*/
importWalletViaFile: (page: Page, filePath: string, password: string) => Promise<void>;
/**
* Build (once) a STAGING bundle of the real app — gate ON + the shared wallet
* TextCode baked in — serve it statically, and return its URL. Used by the
* human-flow e2e to exercise the real AccessGateScreen. Memoised.
*/
ensureStagingApp: () => Promise<string>;
}
export const pool: BrowserPool = {
@@ -76,19 +61,12 @@ export const pool: BrowserPool = {
useRealBroker: false,
permissions: [],
sharedWalletState: null,
sharedWalletPassword: '',
setupBrokerPage: async () => {
throw new Error('browserPool not initialized — did BeforeAll run?');
},
completeBrokerLogin: async () => {
throw new Error('browserPool not initialized — did BeforeAll run?');
},
importWalletViaFile: async () => {
throw new Error('browserPool not initialized — did BeforeAll run?');
},
ensureStagingApp: async () => {
throw new Error('browserPool not initialized — did BeforeAll run?');
},
};
/**
+223 -144
View File
@@ -1,31 +1,22 @@
import { Before, After, BeforeAll, AfterAll, Status, setDefaultTimeout } from '@cucumber/cucumber';
import { chromium, type Browser, type BrowserContext, type Page, type Frame } from 'playwright';
import { execSync, spawn, type ChildProcess } from 'child_process';
import { execSync, execFileSync, spawn, type ChildProcess } from 'child_process';
import * as http from 'http';
import * as fs from 'fs';
import * as path from 'path';
import type { FestipodWorld } from './world';
import { pool } from './browserPool';
// The path the app asks the SDK to fetch its wallet from — ONE constant, shared
// with the app, so the harness server and the bundle can never disagree on it.
import { SHARED_WALLET_FILE_URL } from '../utils/sharedWallet';
setDefaultTimeout(90000);
// PER-SCENARIO FRESH VIRTUAL WALLET (T03.k). The shim keys each emulated account
// (its own private virtual wallet) by the NORMALIZED app-level identifier read
// from localStorage['festipod.account.identifier'] on the harness origin. When
// every @data scenario logs in as the SAME fixed user, that ONE virtual wallet
// accumulates every doc any prior scenario/run ever wrote → per-doc anchored
// reads fan out over hundreds of docs → 90s timeouts. Giving each scenario a
// UNIQUE identifier hands it a FRESH, EMPTY virtual wallet, so reads stay O(what
// THIS scenario provisions) and are fast + independent. A monotonic counter +
// per-run nonce guarantees uniqueness within and across runs; it normalizes to
// itself (lowercase, `@`-free) and is disjoint from the reserved `@index`
// account (whose shim key uses a sentinel prefix `normalizeIdentifier` can't emit).
const RUN_NONCE = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
let scenarioSeq = 0;
function freshScenarioIdentifier(): string {
scenarioSeq += 1;
return `test-${RUN_NONCE}-${scenarioSeq}`;
}
// A BROWSER CONTEXT IS ONE USER, and nothing here names or selects that user:
// signing in is `init(…)` then `await ensureIdentity()`, and `ensureIdentity()`
// is what says who you are. The harness provisions the deployment's wallet and
// opens pages; who those pages come up as is the SDK's answer, never the
// harness's. Two users means two browser contexts, each with its own wallet.
let browser: Browser;
let browserContext: BrowserContext;
@@ -64,12 +55,6 @@ async function launchWalletContext(): Promise<BrowserContext> {
permissions: CONTEXT_PERMISSIONS,
args: LAUNCH_ARGS,
});
// The persistent context drives @data/@e2e (which exercise the screens, not
// the access gate). Disable the gate there so the real app renders directly.
// Fresh contexts (@humain/@multibrowser) don't get this → gate ON by default.
await ctx.addInitScript(() => {
(globalThis as Record<string, unknown>).__FESTIPOD_ACCESS_GATE_DISABLED__ = true;
});
return ctx;
}
@@ -119,6 +104,29 @@ const HARNESS_NG_OUT = path.join('dist', 'test-harness-ng.js');
// Persistent Chromium profile for NG wallet (not the user's daily browser)
const PLAYWRIGHT_PROFILE = path.resolve('.playwright-profile');
// `.env` IS how this project declares the two wallet variables (see .env.example),
// and everything that serves the app runs under BUN, which loads that file by
// itself. Cucumber does not: it runs under NODE (see the `cucumber:run` script),
// which loads nothing — so a suite reading `process.env` alone finds the wallet
// material missing while the very same variables are configured and working for
// the app. Load the file the way Bun would. The real environment WINS (Node's
// loader never overwrites an already-set variable), so an operator exporting the
// variables still overrides the file, exactly as for the app.
try {
process.loadEnvFile();
} catch {
// No .env here — the environment is then the only source, which is fine.
}
// THE DEPLOYMENT'S WALLET MATERIAL — read from the SAME two environment variables
// the app build reads (see build.ts): the password `define`d into the browser
// bundle, and the wallet file served at SHARED_WALLET_FILE_URL. The harness is a
// deployment of the app's own code, so it has to hand its bundle the same two
// things; without them `configure()` receives no `sharedWallet`, `ensureIdentity()`
// rejects, and NO scenario ever signs in.
const SHARED_WALLET_PASSWORD_ENV = process.env.FESTIPOD_SHARED_WALLET_PASSWORD ?? '';
const SHARED_WALLET_FILE_ENV = process.env.FESTIPOD_SHARED_WALLET_FILE ?? '';
let harnessServer: http.Server | null = null;
let harnessPort = 0;
let useRealBroker = false;
@@ -127,26 +135,9 @@ let useRealBroker = false;
let appServerProcess: ChildProcess | null = null;
let appPort = 0;
// Human-flow e2e: a STAGING build of the app (gate ON + shared wallet baked in),
// served statically. Built lazily (only when the human-flow scenario runs).
const STAGING_OUTDIR = path.resolve('dist-staging');
let stagingServer: http.Server | null = null;
let stagingAppUrl = '';
const WALLET_NAME = 'festipod-tests';
const WALLET_PASSWORD = 'festipod-tests';
// The SHARED wallet for the assisted-import e2e: a static .ngw file placed at the
// worktree root + its password (identifier = password, per the e2e wallet setup).
const E2E_WALLET_PASSWORD = 'festipod-e2e-tests';
function findE2eWalletFile(): string {
const f = fs.readdirSync(process.cwd()).find((x) => x.endsWith('.ngw'));
if (!f) {
throw new Error('No .ngw wallet file at the worktree root — add the festipod-e2e-tests wallet file.');
}
return path.resolve(f);
}
/**
* Navigate through the NG broker to load an app in its iframe.
* Handles wallet login and returns the app's Frame.
@@ -238,74 +229,6 @@ async function completeBrokerLogin(page: Page, appUrl: string, walletPassword: s
return appFrame;
}
/**
* Drive the standalone nextgraph.eu "Import a Wallet File" flow: upload the .ngw
* file and unlock with the password. The wallet FILE is the STATIC, reusable
* assisted-import primitive (a TextCode is a transient 5-min device-to-device
* transfer — unusable to embed; see knowledge_broker-import-constraint). After
* this, the page's context holds the wallet.
*/
async function importWalletViaFile(page: Page, filePath: string, password: string): Promise<void> {
await page.goto('https://nextgraph.eu/#/wallet/login', { waitUntil: 'domcontentloaded' });
// Let the SPA render and the file input attach before uploading (uploading too
// early yields an EncryptionError — the wallet doesn't load).
await page.waitForTimeout(3000);
await page.locator('input[type=file]').waitFor({ state: 'attached', timeout: 15000 });
await page.setInputFiles('input[type=file]', filePath);
// A password prompt appears to unlock the wallet ("Enter your password").
const passwordInput = page.locator('input[type=password]').first();
await passwordInput.waitFor({ state: 'visible', timeout: 15000 });
await passwordInput.fill(password);
await passwordInput.press('Enter');
const confirm = page.getByRole('button', { name: /Confirm/i });
if (await confirm.isVisible({ timeout: 2000 }).catch(() => false)) await confirm.click().catch(() => {});
await page.waitForTimeout(8000); // unlock + verifier bootstrap from the broker
}
/**
* Build (once) a STAGING bundle of the real app — gate ON + the shared wallet
* FILE + password baked in — and serve it statically. Returns its URL. Lets the
* human-flow e2e exercise the real AccessGateScreen (which only renders in a
* staging build). Memoised; the build is cheap (~100-300ms).
*/
async function ensureStagingApp(): Promise<string> {
if (stagingAppUrl) return stagingAppUrl;
// Build into a SEPARATE outdir so it never collides with the harness bundles.
// Gate is ON by default (no ACCESS_GATE_DISABLED). The build copies the .ngw to
// dist-staging/shared-wallet.ngw + bakes the password.
execSync('bun run build.ts --outdir=dist-staging', {
env: {
...process.env,
FESTIPOD_SHARED_WALLET_FILE: findE2eWalletFile(),
FESTIPOD_SHARED_WALLET_PASSWORD: E2E_WALLET_PASSWORD,
},
stdio: 'pipe',
});
const mime: Record<string, string> = {
'.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css',
'.svg': 'image/svg+xml', '.map': 'application/json', '.json': 'application/json',
'.ico': 'image/x-icon', '.png': 'image/png', '.woff2': 'font/woff2',
};
stagingServer = http.createServer((req, res) => {
const urlPath = (req.url || '/').split('?')[0]!;
let filePath = path.join(STAGING_OUTDIR, urlPath === '/' ? 'index.html' : urlPath);
if (!filePath.startsWith(STAGING_OUTDIR) || !fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
filePath = path.join(STAGING_OUTDIR, 'index.html'); // SPA fallback
}
res.writeHead(200, { 'Content-Type': mime[path.extname(filePath)] || 'application/octet-stream' });
fs.createReadStream(filePath).pipe(res);
});
const port = await new Promise<number>((resolve) => {
stagingServer!.listen(0, '127.0.0.1', () => resolve((stagingServer!.address() as { port: number }).port));
});
stagingAppUrl = `http://127.0.0.1:${port}`;
console.log(`[Staging] App (gate ON, wallet baked) on ${stagingAppUrl}`);
return stagingAppUrl;
}
/**
* Automated wallet creation + login on nextgraph.eu.
* Flow:
@@ -413,8 +336,40 @@ BeforeAll({ timeout: 10 * 60 * 1000 }, async function () {
// Try to build the real broker harness
try {
execSync(`bun build ${HARNESS_NG_ENTRY} --outfile ${HARNESS_NG_OUT} --bundle`, { stdio: 'pipe' });
// The harness bundle needs the SAME wallet material as the app bundle — the
// password as a build-time global, the file served by the harness's own HTTP
// server. Missing either one means no sign-in is possible at all, so say so
// instead of building a bundle that can only fail later, opaquely.
if (!SHARED_WALLET_PASSWORD_ENV.trim() || !SHARED_WALLET_FILE_ENV.trim()) {
throw new Error(
'Shared wallet material missing — set FESTIPOD_SHARED_WALLET_PASSWORD and ' +
'FESTIPOD_SHARED_WALLET_FILE (see .env.example). Without them the harness ' +
'bundle has no wallet to open: ensureIdentity() rejects and no @data/@e2e ' +
'scenario can sign in.',
);
}
const walletFilePath = path.resolve(SHARED_WALLET_FILE_ENV);
if (!fs.existsSync(walletFilePath)) {
throw new Error(
`Shared wallet file not found: ${walletFilePath} (FESTIPOD_SHARED_WALLET_FILE).`,
);
}
const walletFileBytes = fs.readFileSync(walletFilePath);
// `--define` mirrors build.ts exactly (same global name, same JSON encoding).
// execFileSync, not a shell string: the password is passed as an argv entry,
// so no quoting of the value can ever mangle or leak it.
execFileSync(
'bun',
[
'build', HARNESS_NG_ENTRY, '--outfile', HARNESS_NG_OUT, '--bundle',
'--define',
`globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__=${JSON.stringify(SHARED_WALLET_PASSWORD_ENV)}`,
],
{ stdio: 'pipe' },
);
console.log(`[Harness] Built NG (${(fs.statSync(HARNESS_NG_OUT).size / 1024).toFixed(0)} KB)`);
console.log(`[Harness] Shared wallet: ${walletFilePath} → ${SHARED_WALLET_FILE_URL}`);
// Ensure wallet exists in persistent profile (opens browser if needed)
await ensureAuth();
@@ -432,9 +387,16 @@ BeforeAll({ timeout: 10 * 60 * 1000 }, async function () {
</body>
</html>`;
harnessServer = http.createServer((req, res) => {
const urlPath = req.url?.split('?')[0];
if (req.url === '/harness.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
res.end(harnessBundle);
} else if (urlPath === SHARED_WALLET_FILE_URL) {
// The wallet FILE, at the very path the bundle hands the SDK
// (`SHARED_WALLET_FILE_URL`) — the harness origin serves its own wallet,
// exactly as `src/index.ts` does for the app origin.
res.writeHead(200, { 'Content-Type': 'application/octet-stream' });
res.end(walletFileBytes);
} else if (req.url?.startsWith('/blank')) {
// Minimal page on the harness origin (no NG stack) — used by
// multi-browser isolation checks that only need localStorage.
@@ -473,7 +435,19 @@ BeforeAll({ timeout: 10 * 60 * 1000 }, async function () {
env: { ...process.env, PORT: String(appPort), NODE_ENV: 'production' },
stdio: 'pipe',
cwd: process.cwd(),
// OWN PROCESS GROUP, so teardown can signal the whole tree. `bun` on PATH
// resolves to a shell shim that launches the real binary as ITS child and
// merely waits — no `exec` — so signalling the direct child kills the shim
// only and leaves the server running, re-parented to init. Detaching gives
// the pair a group id (= this pid) that `process.kill(-pid)` can reach.
detached: true,
});
// DRAIN the pipes. `stdio: 'pipe'` gives each stream a 64 KB kernel buffer;
// nobody reads them, so a chatty server fills one mid-run and then BLOCKS on
// its next write — the suite would hang with no error and no output to explain
// it. Flowing mode discards what we do not need instead of accumulating it.
appServerProcess.stdout?.resume();
appServerProcess.stderr?.resume();
// Wait for app server to respond
await new Promise<void>((resolve, reject) => {
const deadline = Date.now() + 15000;
@@ -499,9 +473,6 @@ BeforeAll({ timeout: 10 * 60 * 1000 }, async function () {
pool.permissions = CONTEXT_PERMISSIONS;
pool.setupBrokerPage = setupBrokerPage;
pool.completeBrokerLogin = completeBrokerLogin;
pool.importWalletViaFile = importWalletViaFile;
pool.ensureStagingApp = ensureStagingApp;
pool.sharedWalletPassword = E2E_WALLET_PASSWORD;
// Warm up the persistent wallet profile through the broker, then capture its
// storage state. Injecting this into fresh contexts provisions the SHARED
@@ -580,20 +551,6 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
// the run self-heals instead of cascading failures across the rest.
this.page = await newWalletPageResilient();
// FRESH VIRTUAL WALLET per scenario (see freshScenarioIdentifier above). Set a
// UNIQUE app-level identifier into localStorage['festipod.account.identifier']
// on EVERY origin (the init script runs in each frame before its scripts do —
// including the harness iframe on 127.0.0.1). At mount the harness's
// IdentityStore.get() then reads THIS fresh identifier, so `if (!identifier)
// login(DEFAULT_HARNESS_USER)` is skipped and the scenario runs on a fresh,
// empty virtual wallet. Overwrites any value persisted in the Chromium profile
// (init scripts run on each navigation), so no accumulated wallet leaks in.
const freshIdentifier = freshScenarioIdentifier();
(this as any).freshIdentifier = freshIdentifier;
await this.page.addInitScript((u: string) => {
try { window.localStorage.setItem('festipod.account.identifier', u); } catch { /* opaque origin */ }
}, freshIdentifier);
// Capture console for debugging AND collect into the World so smoke
// scenarios can assert no runtime error was emitted during the connected
// boot (guards the "page blanche once connected" render-crash class).
@@ -615,18 +572,34 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
const harnessUrl = `http://127.0.0.1:${harnessPort}`;
this.appFrame = await setupBrokerPage(this.page!, harnessUrl);
// Wait for NG session + useShape + bridge
await this.appFrame.waitForFunction(
// Wait for NG session + useShape + bridge — OR for the harness to publish a
// terminal error. A harness that cannot sign in renders an observable
// `#harness-status[data-harness-error]` (see harness-ng.tsx); racing it
// against the bridge turns a total sign-in failure into an immediate,
// named failure instead of an anonymous 30s timeout.
const bridgeReady = this.appFrame.waitForFunction(
() => (window as any).__testData?.ready === true,
undefined,
{ timeout: 30000 },
);
const harnessError = this.appFrame
.waitForSelector('#harness-status[data-harness-error]', { timeout: 30000 })
.then(
async (el) => {
const detail = await el?.getAttribute('data-harness-error');
const state = await el?.textContent();
throw new Error(`Harness never became usable (${state}): ${detail}`);
},
// No error state within the window: let the bridge wait decide.
() => new Promise<never>(() => { /* never settles */ }),
);
await Promise.race([bridgeReady, harnessError]);
// NO per-scenario registry/wallet reset needed anymore (was T03.j
// resetDataState). Each @data scenario now runs under a UNIQUE identifier
// (freshScenarioIdentifier, set into localStorage above), so the shim hands it
// a FRESH, EMPTY virtual wallet whose account registry starts empty by
// construction — nothing to purge. This also drops the ≤10s reset cost that
// shared the Before hook's budget with the (slow) broker login.
// NO per-scenario reset. The suite does not purge the wallet between
// scenarios: the old `clearWallet` fan-out enumerated every entity document
// and cost up to 10s out of the Before hook's budget, which it shared with
// the (slow) broker login. A scenario provisions what it needs and asserts
// on that, rather than on the wallet being empty.
} else {
// Mock mode: load harness directly
await this.page!.setContent('<!DOCTYPE html><html><body><div id="root"></div></body></html>');
@@ -687,6 +660,93 @@ After({ timeout: 10000 }, async function (this: FestipodWorld, scenario) {
this.cleanup();
});
/**
* Await `work`, but never longer than `ms`. A close() that never settles must not
* stop the REMAINING resources from being released — teardown continues and the
* unreleased handle is named in the log instead of silently stalling the process.
* The timer is always cleared, so this guard never becomes a handle of its own.
*/
async function withDeadline(label: string, ms: number, work: Promise<unknown>): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined;
const expired = Symbol('expired');
const deadline = new Promise<typeof expired>((resolve) => { timer = setTimeout(() => resolve(expired), ms); });
try {
const outcome = await Promise.race([work.then(() => undefined), deadline]);
if (outcome === expired) console.warn(`[Teardown] ${label} did not settle within ${ms}ms — moving on`);
} finally {
if (timer) clearTimeout(timer);
}
}
/**
* Stop the E2E app server and RELEASE ITS PIPES.
*
* `spawn(..., { stdio: 'pipe' })` opens three socketpairs whose ends belong to
* THIS process, plus the child handle. `kill()` alone releases none of them
* synchronously, and nothing ever reads the child's stdout/stderr, so those two
* sockets never reach EOF: they stay ref'd on the event loop and the suite can
* never exit on its own. So: signal the whole PROCESS GROUP (the shim waiting on
* the real server is the direct child — see the `detached` note at the spawn),
* WAIT for it to actually die (that is what retires its process handle), escalate
* if it ignores SIGTERM, then destroy the three stdio sockets we own.
*/
async function stopAppServer(child: ChildProcess): Promise<void> {
const hasExited = () => child.exitCode !== null || child.signalCode !== null;
const exited: Promise<void> = hasExited()
? Promise.resolve()
: new Promise<void>((resolve) => {
child.once('exit', () => resolve());
child.once('error', () => resolve());
});
// Negative pid = the whole group (shim + server). Falls back to the direct
// child if the group is already gone or was never created.
const signalTree = (signal: NodeJS.Signals) => {
try {
if (child.pid === undefined) return;
process.kill(-child.pid, signal);
} catch {
try { child.kill(signal); } catch { /* already reaped */ }
}
};
// The shim exits the moment it is signalled, so ITS `exit` event says nothing
// about the server behind it. Signal 0 probes the group instead: it is alive
// for as long as any member — i.e. the server — is.
const groupAlive = (): boolean => {
if (child.pid === undefined) return false;
try { process.kill(-child.pid, 0); return true; } catch { return false; }
};
const waitForGroupToDie = async (ms: number): Promise<void> => {
const deadline = Date.now() + ms;
while (groupAlive() && Date.now() < deadline) {
await new Promise<void>((resolve) => { setTimeout(resolve, 100); });
}
};
signalTree('SIGTERM');
await waitForGroupToDie(5000);
if (groupAlive()) {
console.warn('[Teardown] app server ignored SIGTERM — SIGKILL');
signalTree('SIGKILL');
await waitForGroupToDie(2000);
}
if (groupAlive()) console.warn('[Teardown] app server process group still alive after SIGKILL');
// Let Node reap the direct child, so its process handle leaves the event loop.
await withDeadline('appServer child reap', 2000, exited);
if (!hasExited()) console.warn('[Teardown] app server child not reaped');
for (const stream of [child.stdin, child.stdout, child.stderr]) stream?.destroy();
}
/**
* Close the harness HTTP server. `close()` only stops accepting and then waits
* for every established connection to end — a keep-alive socket left behind by a
* browser that went away would hold it (and the process) open forever. Drop those
* sockets explicitly first, then wait for the listener itself.
*/
async function stopHarnessServer(server: http.Server): Promise<void> {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
AfterAll(async function () {
// Teardown must be fully defensive: a Playwright context/browser can already be
// closed by the time we get here (multi-browser scenarios that closed their own
@@ -695,15 +755,34 @@ AfterAll(async function () {
// flush, losing the whole report and masking the real pass/fail. Each step is
// isolated so a flake in one never blocks the rest. This turns the documented
// "browserContext already closed" teardown flake into a non-fatal event.
const safe = async (label: string, fn: () => Promise<void> | void) => {
try { await fn(); } catch (e) { console.warn(`[Teardown] ${label} failed (non-fatal):`, (e as Error).message); }
//
// ORDER = REVERSE OF ACQUISITION (harness server → wallet context → fresh
// browser → app server), so nothing is torn down while something that talks to
// it is still alive. In particular the harness server goes LAST: the browsers
// hold keep-alive connections to it, and closing a server still has to wait for
// its connections.
const safe = async (label: string, ms: number, fn: () => Promise<unknown> | unknown) => {
try {
await withDeadline(label, ms, Promise.resolve(fn()));
} catch (e) {
console.warn(`[Teardown] ${label} failed (non-fatal):`, (e as Error).message);
}
};
await safe('browserContext.close', () => browserContext?.close());
await safe('freshBrowser.close', () => freshBrowser?.close());
await safe('browser.close', () => browser?.close());
await safe('harnessServer.close', () => new Promise<void>((resolve) => harnessServer ? harnessServer.close(() => resolve()) : resolve()));
await safe('appServer.kill', () => { if (appServerProcess) { appServerProcess.kill(); appServerProcess = null; } });
await safe('stagingServer.close', () => new Promise<void>((resolve) => stagingServer ? stagingServer.close(() => resolve()) : resolve()));
await safe('stagingOutdir.rm', () => fs.existsSync(STAGING_OUTDIR) ? fs.promises.rm(STAGING_OUTDIR, { recursive: true, force: true }) : undefined);
if (appServerProcess) {
const child = appServerProcess;
appServerProcess = null;
await safe('appServer.stop', 10000, () => stopAppServer(child));
}
await safe('freshBrowser.close', 30000, () => freshBrowser?.close());
freshBrowser = null;
pool.freshBrowser = null;
await safe('browserContext.close', 30000, () => browserContext?.close());
pool.walletContext = null;
await safe('browser.close', 30000, () => browser?.close());
if (harnessServer) {
const server = harnessServer;
harnessServer = null;
await safe('harnessServer.close', 10000, () => stopHarnessServer(server));
}
console.log('Festipod BDD tests completed.');
});
+131 -574
View File
@@ -7,97 +7,88 @@
*
* Exposes window.__testData for Playwright-driven Cucumber steps.
*/
import React, { useEffect, useState, useRef } from 'react';
import { useEffect, useState, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import { NextGraphProvider, useNextGraph } from '../context/NextGraphContext';
import { AccountProvider, useAccount } from '../context/AccountContext';
import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataContext';
// useShape routed through the lib (SDK-identical surface); caps from /polyfill.
import { useShape, docs, inbox as docsInbox, isNuri } from '@ng-eventually/client';
import type { Nuri } from '@ng-eventually/client';
import { getCaps, getCurrentUser, setCurrentUser, resetCaps, connectedUser } from '@ng-eventually/client/polyfill';
// Relationship is an app concept: directed grants come from the app's own module.
import { declareConnections, resetConnections } from '../utils/connections';
import { hostInboxNuri as regInboxNuri } from '../data/registration';
// The write side of one-document-per-entity: an entity's RDF goes straight into
// its OWN document (rule_document-per-entity), never into a store-level document.
import { writeEntity, ENTITY_TYPE, iri, bool } from '../data/entityWrites';
import type { DeepSignalSet } from '@ng-eventually/client';
// doc_create goes through the lib's `docs` primitive (T01.a): it calls the REAL
// injected `ng` directly (never the public proxy), so postMessage marshaling
// stays intact (no DataCloneError). See decision_2026-06-17_eventually-library.
// Everything NextGraph-shaped comes from the ONE published SDK entry.
import { useShape, docs, inbox as sdkInbox, ensureIdentity } from '@ng-eventually/polyfill';
import type { Nuri, DeepSignalSet } from '@ng-eventually/polyfill';
// Placement — taken from the app's own module, so the harness enumerates exactly
// what the app enumerates. Placement is named by scope alone: "my documents" are
// the signed-in session's, and there is no identity to name.
import { listMyEntityDocs, openDocumentInbox, resolveScopeGraph } from '../utils/storeRegistry';
import { setCurrentPrincipal } from '../utils/currentPrincipal';
import { materializeAttendance, NOTIF_TYPE_NEW_PARTICIPANT } from '../data/registration';
import type { RegistrationPayload } from '../data/registration';
import {
FpEventShapeType,
FpUserProfileShapeType,
FpParticipationShapeType,
} from '../shapes/orm/festipodShapes.shapeTypes';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
import { normalizeIdentifier } from '../context/AccountContext';
// ============================================================================
// App — uses real providers (same tree as the real app)
// ============================================================================
// Default @data identity — the seed owner. The harness has no login UI, so we
// establish a default account (as the real app would after login) so the SDK
// knows WHO is reading. Without a current identity the per-document ReadCap
// filter passes only PUBLIC documents, so the current user's own PROTECTED
// entities (profile, participations) would be hidden and never round-trip.
const DEFAULT_HARNESS_USER = '@mariedupont';
/**
* Step boundary: an event id crosses from a Cucumber step as a plain string. An
* event IS its own document (rule_document-per-entity), so its id is a document
* NURI — anything else names no document and has no inbox. Narrow here rather than
* let a bad id reach the SDK as a silent no-op.
*/
/** The stand-in PUBLIC document of the T03.b probe — a literal, so it is a NURI
* by construction with nothing to narrow. */
const PUBLIC_PROBE: Nuri = 'did:ng:o:public-probe';
function asEventDoc(eventId: string): Nuri {
if (!isNuri(eventId)) {
throw new Error(`[HarnessNG] "${eventId}" is not a document NURI — an event id is its document.`);
}
return eventId;
}
function DataHarnessNG() {
return (
<NextGraphProvider>
<AccountProvider>
<HarnessLogin />
<FestipodDataProvider>
<HarnessRouter />
</FestipodDataProvider>
</AccountProvider>
<FestipodDataProvider>
<HarnessRouter />
</FestipodDataProvider>
</NextGraphProvider>
);
}
/** Establish the default @data identity once, so `setCurrentUser` fires (via the
* AccountProvider effect) and the current user can read their own protected
* entities. Mirrors the real app's post-login state. */
function HarnessLogin() {
const { identifier, login } = useAccount();
useEffect(() => {
if (!identifier) login(DEFAULT_HARNESS_USER);
}, [identifier, login]);
return null;
}
// Wait for NG connection before exposing the test bridge
// Wait for the NG connection AND for the ONE identity await before exposing the
// test bridge — the same order the real app's AuthGate imposes. `ensureIdentity()`
// IS signing in: it takes no identifier, resolves who we are and does the
// connection work, so nothing may read before it resolves.
function HarnessRouter() {
const { status } = useNextGraph();
const [identityReady, setIdentityReady] = useState(false);
// Why signing in did NOT settle. A harness that failed to sign in must look
// FAILED, never "still waiting": the difference between the two is the whole
// difference between a broken suite and a slow one.
const [identityError, setIdentityError] = useState<string | null>(null);
if (status === 'connected') {
return <ConnectedHarness />;
useEffect(() => {
if (status !== 'connected' || identityReady || identityError) return;
let cancelled = false;
void ensureIdentity()
.then(principal => {
// Same as the app's AuthGate: signing in RETURNS who we are, and the
// harness publishes it the same way, so the tree under test sees exactly
// what the app's tree sees.
setCurrentPrincipal(principal);
if (!cancelled) setIdentityReady(true);
})
.catch(err => {
console.error('[HarnessNG] ensureIdentity failed:', err);
if (!cancelled) setIdentityError(err instanceof Error ? err.message : String(err));
});
return () => { cancelled = true; };
}, [status, identityReady, identityError]);
// TERMINAL STATES — each observable to a Playwright locator, and none of them
// exposes the test bridge: a scenario must never read through a tree that never
// signed in. `data-harness-error` carries the reason so the failure names itself.
if (identityError) {
return (
<div id="harness-status" data-harness-error={identityError}>IDENTITY_ERROR</div>
);
}
if (status === 'error') {
return <div id="harness-status">ERROR</div>;
}
if (status === 'connected' && identityReady) {
return <ConnectedHarness />;
}
return <div id="harness-status">WAITING_FOR_SESSION</div>;
}
@@ -105,18 +96,48 @@ function HarnessRouter() {
// Connected harness — exposes window.__testData through real providers
// ============================================================================
/**
* Resolve, ONCE, the graph each scope names — then mount the reads on them.
*
* The raw ORM sets below are anchored per SCOPE, and an entity's scope is a
* domain fact (rule_document-per-entity): EVENTS are public; user profiles and
* participations are protected. The app holds no store id and builds no
* `did:ng:` NURI of its own — `resolveScopeGraph(scope)` is the SDK's answer to
* "which graph is this scope", and the only one the harness is entitled to.
*
* The reads mount only once both graphs are known, so no shape set is ever
* anchored on `undefined`.
*/
function ConnectedHarness() {
const [graphs, setGraphs] = useState<{ publicGraph: Nuri; protectedGraph: Nuri } | null>(null);
const [scopeError, setScopeError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void Promise.all([resolveScopeGraph('public'), resolveScopeGraph('protected')])
.then(([publicGraph, protectedGraph]) => {
if (!cancelled) setGraphs({ publicGraph, protectedGraph });
})
.catch(err => {
console.error('[HarnessNG] scope resolution failed:', err);
if (!cancelled) setScopeError(err instanceof Error ? err.message : String(err));
});
return () => { cancelled = true; };
}, []);
// Terminal — same contract as the identity failure: observable, and no bridge.
if (scopeError) {
return <div id="harness-status" data-harness-error={scopeError}>SCOPE_ERROR</div>;
}
if (!graphs) {
return <div id="harness-status">RESOLVING_SCOPES</div>;
}
return <ScopedHarness publicGraph={graphs.publicGraph} protectedGraph={graphs.protectedGraph} />;
}
function ScopedHarness({ publicGraph, protectedGraph }: { publicGraph: Nuri; protectedGraph: Nuri }) {
const ngCtx = useNextGraph();
const appData = useFestipodData();
// Identity switch (two-identity isolation): the app has no page reload on a
// faux-logout+re-login (shared-wallet stopgap), so switching identity here
// means calling AccountContext.login() with a new identifier — which drives the
// `prevOwnerRef` reset effect in FestipodDataContext. Exposed to steps so a @data
// scenario can bring up identity A, then a genuinely-different identity B on the
// SAME wallet and assert B is isolated.
const account = useAccount();
const accountRef = useRef(account);
accountRef.current = account;
// The bridge is built once inside an effect (below) and its getters close over
// `appData`. `appData` is a NEW object every render (its `events`/`users` reflect
// the latest per-entity reads), so a captured snapshot goes STALE — after
@@ -126,29 +147,14 @@ function ConnectedHarness() {
const appDataRef = useRef(appData);
appDataRef.current = appData;
// Private store NURI — the inbox shim anchor + the ReadCap-governed document.
const privateNuri: Nuri | undefined = ngCtx.session && `did:ng:${ngCtx.session.private_store_id}`;
// Protected store NURI — T02.h (axe A): the shareable DOMAIN entities (events,
// users, participations) now live in the real protected native store, so the
// harness's raw ORM sets subscribe there too (matching FestipodDataContext).
const protectedNuri: Nuri | undefined = ngCtx.session && `did:ng:${ngCtx.session.protected_store_id}`;
const events = useShape(FpEventShapeType, protectedNuri) as DeepSignalSet<FpEvent>;
const users = useShape(FpUserProfileShapeType, protectedNuri) as DeepSignalSet<FpUserProfile>;
const participations = useShape(FpParticipationShapeType, protectedNuri) as DeepSignalSet<FpParticipation>;
// Each raw ORM set is anchored on the graph of the scope ITS OWN entity lives
// in — events public, profiles and participations protected — matching
// FestipodDataContext's `useShapeQuery(shape, scope)` reads.
const events = useShape(FpEventShapeType, publicGraph) as DeepSignalSet<FpEvent>;
const users = useShape(FpUserProfileShapeType, protectedGraph) as DeepSignalSet<FpUserProfile>;
const participations = useShape(FpParticipationShapeType, protectedGraph) as DeepSignalSet<FpParticipation>;
const [bridgeReady, setBridgeReady] = useState(false);
// Read-filter validation: <FilterProbe> mounts a useShape over THIS document and
// returns the read-filtered VIEW of it. Which document depends on the probe: the
// store-root one for the mono-store read-filter scenario, a real per-entity
// document for the protected-connections one.
const [filterDoc, setFilterDoc] = useState<Nuri | null>(null);
// Stopgap multi-store validation: a doc created on demand via doc_create,
// mounted into a real useShape({graphs}) by <SmokeProbe>.
const [smokeDoc, setSmokeDoc] = useState<string | null>(null);
// Per-entity fan-out validation: several entity docs read together.
const [fanoutGraphs, setFanoutGraphs] = useState<string[]>([]);
// T02.h gating: mount a useShape(protectedNuri) to open the protected repo.
const [protectedActive, setProtectedActive] = useState(false);
useEffect(() => {
// Small delay for useShape to populate
@@ -197,19 +203,6 @@ function ConnectedHarness() {
get currentUserId() { return AD().currentUserId || currentUserId; },
session,
// --- IDENTITY SWITCH (two-identity isolation) ----------------------
/** Faux-logout + re-login under a NEW identifier on the SAME wallet (no
* page reload), exactly as the real app's AccessGate/Settings flow does.
* Drives AccountContext.login → setCurrentUser + the FestipodDataContext
* `prevOwnerRef` reset. Returns the normalized id now in effect. */
switchIdentity(identifier: string) {
accountRef.current.login(identifier);
return normalizeIdentifier(identifier);
},
/** The current app-level identifier (localStorage-backed). */
currentIdentifier() {
return accountRef.current.identifier;
},
/** Titles of the events the CURRENT user PARTICIPATES in — exactly what the
* HOME screen shows (`getUserEvents(currentUserId)`). Used by the
* two-identity isolation test to assert a fresh identity's home is empty. */
@@ -252,28 +245,6 @@ function ConnectedHarness() {
await AD().leaveEvent(eventId, userId);
},
// --- RAW store-root path (the mono-store read-filter probe ONLY) -------
// `read-filter.feature` states the filter's all-or-nothing behaviour on ONE
// document holding SEVERAL items — the mono-store layout — so it governs the
// STORE-ROOT protected document (`documentNuri` = protectedNuri) via
// <FilterProbe> and needs participations written into THAT document. These
// raw helpers keep that probe on the exact document it governs. Nothing else
// may use them: the app (and the protected-connections probe) writes one
// document per entity (rule_document-per-entity).
get rawParticipations() { return participations; },
rawJoin(eventId: string, userId: string) {
const already = [...participations].some(p => p.event === eventId && p.user === userId);
if (already) return;
participations.add({
'@graph': protectedNuri,
'@type': 'http://festipod.org/Participation',
'@id': '',
event: eventId,
user: userId,
isConfirmed: true,
} as FpParticipation);
},
// --- Real app-path registration (T02.c) ----------------------------
// These go through the REAL FestipodDataContext mutations (appData), so
// the @data scenario faces the same inbox-deposit + notification +
@@ -330,19 +301,26 @@ function ConnectedHarness() {
const uid = AD().currentUserId || userAdapter()[0]?.['@id'] || '';
return AD().isParticipating(eventId, uid);
},
/** The host inbox NURI for an event (domain glue, T02.c). An event id IS its
* document NURI; a step that passes anything else has no inbox to name. */
async eventInboxNuri(eventId: string) {
return regInboxNuri(asEventDoc(eventId));
/** The inbox address of an event, as ITS OWNER obtains it. An event id IS
* its document NURI (rule_document-per-entity) — hence the `Nuri` type,
* which is what `openDocumentInbox` takes. Only the owner opens that
* document's inbox: a depositor never sees the address, it names the
* document instead. */
async eventInboxNuri(eventId: Nuri) {
return openDocumentInbox(eventId);
},
/** Materialize the raw registration deposits for an event (curator). The
* event's inbox may also carry other kinds, so filter to this event. */
/** The raw registration deposits addressed to an event's document (read by
* its owner). The event's inbox may also carry other kinds, so filter to
* this event. */
async readInboxDeposits(eventId: string) {
const target = await regInboxNuri(asEventDoc(eventId));
const deposits = await docsInbox.read(target);
return deposits.filter(
(d: any) => d?.payload?.kind === 'new-participant' && d?.payload?.eventId === eventId,
);
const deposits = await sdkInbox.readForDocument(eventId);
return deposits.filter(d => {
// A deposit's payload is opaque to the SDK — the Festipod domain shape
// is `RegistrationPayload`, exactly as `readRegistrationNotifications`
// reads it on the app side.
const p = d.payload as Partial<RegistrationPayload> | null;
return p?.kind === NOTIF_TYPE_NEW_PARTICIPANT && p?.eventId === eventId;
});
},
/** OPTION B — the owner's DERIVED active-registration set for an event
* (`materializeAttendance`), matched on the CANONICAL event-id form. Used
@@ -350,10 +328,9 @@ function ConnectedHarness() {
* is in the active set) rather than an absolute count — an inbox accumulates
* deposits across the wallet's life, so |active| is not bounded to one
* scenario, but "contains this uid" IS deterministic. */
async activeRegistrationUsers(eventId: string) {
const regmod = await import('../data/registration');
const target = await regmod.hostInboxNuri(asEventDoc(eventId));
const active = await regmod.materializeAttendance(target, eventId);
async activeRegistrationUsers(eventId: Nuri) {
const target = await openDocumentInbox(eventId);
const active = await materializeAttendance(target, eventId);
return active.map(r => r.userId);
},
/** Host-facing notifications currently surfaced by the data context. */
@@ -373,23 +350,15 @@ function ConnectedHarness() {
v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
// Participations are ONE DOCUMENT PER ENTITY (protected scope), not the
// store root — so re-query the broker across the protected per-entity
// documents rather than the store-root graph. This stays authoritative
// (bypasses the reactive set): it counts the (event,user) triples actually
// persisted in the broker.
const reg = await import('../utils/storeRegistry');
// Enumerate the CURRENT account's own protected docs — the read-by-need
// path the APP uses (registration.countUserParticipations →
// listMyEntityDocs). Each @data scenario runs under a FRESH virtual account
// (freshScenarioIdentifier in localStorage), whose participation docs live
// ONLY in that account's protected scope index. There is no cross-account
// enumeration any more (a directory would be discovery, which does not
// exist): with no identity there is nothing this session may enumerate, so
// fall back to the SDK's connected identity and otherwise count nothing.
let currentUser = '';
try { currentUser = window.localStorage.getItem('festipod.account.identifier') || ''; } catch { /* opaque origin */ }
const holder = currentUser || getCurrentUser() || '';
const protectedDocs = holder ? await reg.listMyEntityDocs(holder, 'protected') : [];
// store root — so re-query the broker across MY OWN protected per-entity
// documents, the exact read-by-need path the APP uses
// (`registration.countUserParticipations` → `listMyEntityDocs(
// 'protected')`). There is no cross-account enumeration (a directory
// would be discovery, which does not exist), and "mine" needs no
// identity — the session is one user's.
// This stays authoritative (bypasses the reactive set): it counts the
// (event,user) triples actually persisted in the broker.
const protectedDocs = await listMyEntityDocs('protected');
let total = 0;
for (const g of protectedDocs) {
// Anchored default-graph (no `GRAPH` clause): participations are
@@ -457,19 +426,12 @@ function ConnectedHarness() {
* a persistent broker, a real empty state needs the docs' CONTENT cleared
* (the store-root delete of the old model no longer applies). Bounded: on a
* freshly-provisioned wallet there are only a handful of entity docs.
* Scoped to the CONNECTED identity's own documents — enumerating another
* identity's is no longer possible, and clearing them was never this
* harness's business. */
* Scoped to MY OWN documents — enumerating anyone else's is not possible,
* and clearing them was never this harness's business. */
async clearWallet() {
const reg = await import('../utils/storeRegistry');
reg.resetRegistryCache();
let holder = '';
try { holder = window.localStorage.getItem('festipod.account.identifier') || ''; } catch { /* opaque origin */ }
holder = holder || getCurrentUser() || '';
if (!holder) return { cleared: 0 };
const [pub, prot] = await Promise.all([
reg.listMyEntityDocs(holder, 'public'),
reg.listMyEntityDocs(holder, 'protected'),
listMyEntityDocs('public'),
listMyEntityDocs('protected'),
]);
const all = [...new Set([...pub, ...prot])];
await Promise.all(all.map(g =>
@@ -487,257 +449,6 @@ function ConnectedHarness() {
return { cleared: all.length };
},
/**
* PER-SCENARIO STATE ISOLATION (T03.j). The @data suite runs against ONE
* persistent broker-backed wallet, so the emulated account registry (the
* `urn:ng-eventually:shim:Account` triples in the private-store anchor
* graph) ACCUMULATES every account any scenario/run ever provisioned. The
* read path is a fan-out: `allAccounts()` → one SPARQL SELECT per account
* for `listEntityDocs`. As the registry grows unbounded across runs, that
* fan-out gets slow and flaky (same class as the T03.d Chromium saturation).
*
* This gives each @data scenario a CLEAN registry: a SINGLE SPARQL DELETE
* on the ONE private-store anchor graph removes every accumulated Account
* record, so `allAccounts()` collapses to empty and the fan-out is bounded
* to whatever the CURRENT scenario re-provisions (accounts are lazily
* re-created by `ensureAccount` on first use). It is O(1) on ONE graph — NOT
* a fan-out delete (which saturated the browser before, see T03.i's removed
* `authClearParticipation`). Orphaned per-entity docs are simply never
* enumerated once their owning Account record is gone.
*
* Test-infra ONLY: touches the emulation's registry anchor, never the
* product model, the app read path, or the boundary. The lib is untouched;
* this reuses the same anchor NURI (`did:ng:${private_store_id}`) and shim
* vocabulary the lib's `loadShim`/`ensureAccount` use.
*/
async resetDataState() {
const reg = await import('../utils/storeRegistry');
const priv: Nuri = `did:ng:${session.private_store_id}`;
const SHIM = 'urn:ng-eventually:shim';
const t0 = Date.now();
// Delete every Account record (and its identity/doc* predicates) from the
// anchor graph. `?p ?o` with the `a shim:Account` guard scopes the delete
// strictly to registry triples, leaving anything else in the private
// store intact.
const del = `
DELETE { GRAPH <${priv}> { ?acc ?p ?o } }
WHERE {
GRAPH <${priv}> {
?acc a <${SHIM}:Account> ;
?p ?o .
}
}`;
try {
await docs.sparqlUpdate(session.session_id, del, priv);
} catch { /* best-effort — a broker flake must not fail the scenario */ }
// Drop the in-memory account cache so the next registry call re-reads the
// now-empty anchor (else a stale cache would keep the old accounts alive).
reg.resetRegistryCache();
return { resetMs: Date.now() - t0 };
},
/** ONE-TIME CLEANUP (T03.i): the private store accumulated thousands of
* historical inbox-deposit triples across test runs (the old inbox anchor
* = private store), making `loadShim` a 60s+ full-graph scan. Delete every
* inbox Deposit triple from the private store so the shim query is fast
* again. Idempotent; safe (deposits are transient test cruft). New deposits
* now land in a dedicated inbox document (lib fix), so this won't re-grow. */
async cleanPrivateInbox() {
const priv: Nuri = `did:ng:${session.private_store_id}`;
const t0 = Date.now();
const del = `
DELETE { GRAPH <${priv}> { ?s ?p ?o } }
WHERE {
GRAPH <${priv}> {
?s a <urn:ng-eventually:inbox:Deposit> ;
?p ?o .
}
}`;
await docs.sparqlUpdate(session.session_id, del, priv);
return { deleteMs: Date.now() - t0 };
},
// --- ReadCap read-filter validation (see decision_2026-06-17_eventually-library) ---
/** The document (repo NURI) the shareable domain entities live in. After
* T02.h this is the PROTECTED native store (was private) — the ReadCap
* read-filter test governs the document that actually holds the
* participations, so it must track the domain scope. */
documentNuri: protectedNuri,
/**
* Put the domain document under a ReadCap regime: `reader` — and only
* `reader` — HOLDS its key, then the current user becomes `user`. Reading is
* possession, so "who holds it" is established by filing the key WHILE that
* identity is the connected one; there is no grant addressed to a third
* party. The read filter is per-DOCUMENT, so this is all-or-nothing on that
* document — the faithful NextGraph behavior in a mono-store layout.
* <FilterProbe> then exposes window.__readFilter.snapshot() over the view.
*/
governDocument(reader: string, user: string) {
if (!protectedNuri) throw new Error('no protected_store_id in session');
resetCaps();
setCurrentUser(reader);
getCaps().open(protectedNuri, 'protected');
setCurrentUser(user);
setFilterDoc(protectedNuri);
},
/** Switch the current user (does the user now hold the document's cap?). */
setUser(user: string) {
setCurrentUser(user);
},
// --- PROTECTED + connections isolation (T03.b) ----------------------
// Prove, through the SDK's ReadCap filter on the REAL ORM set, that a
// PROTECTED ENTITY DOCUMENT owned by `owner` is:
// - hidden from an UNCONNECTED principal (only owner holds its key);
// - revealed once the app declares the connection owner↔reader;
// - a PUBLIC document stays readable throughout.
// The unit of sharing is the DOCUMENT, and each entity is its own document
// (rule_document-per-entity) — so the probe exercises a real per-entity
// document from `createEntityDoc(owner, 'protected')`, which is exactly what
// `declareConnections` hands over. The PUBLIC probe is published as a repo
// LINK and that link is then handed to the reader: public means "whoever has
// the link reads", not "everyone reads regardless of keys". <FilterProbe>
// exposes the read-filtered VIEW over the owner's protected entity document.
// `connect` calls the app's declareConnections — the domain sharing act.
/**
* Create `owner`'s PROTECTED ENTITY document and write its entity into it,
* as `owner` — the creator is the one who holds the key, so possession is
* established by CREATING, not by declaring anything. Mounts <FilterProbe>
* over THAT document. Returns its NURI and how many entities were written
* (the count the reader must end up seeing).
*
* `resetCaps` runs FIRST: it also clears the enforcement flag, so the very
* next mint (this document's) is what arms the read filter.
*/
async setupProtectedEntity(owner: string) {
const reg = await import('../utils/storeRegistry');
resetCaps();
resetConnections(); // clear the app's relationship registry too
setCurrentUser(owner);
const doc = await reg.createEntityDoc(owner, 'protected');
await writeEntity(doc, ENTITY_TYPE.participation, {
event: iri('urn:pc:event'),
user: iri('urn:pc:p1'),
isConfirmed: bool(true),
});
setFilterDoc(doc);
// One entity, one document — so one item is the whole document.
return { doc, total: 1 };
},
/**
* Bring up the UNCONNECTED reader: `owner` publishes the public probe as a
* repo link and hands it to `reader`, who becomes the connected identity.
* No cap of the protected entity document is handed over — that is what the
* connection is for. Runs AFTER `setupProtectedEntity` and deliberately does
* NOT reset caps: the owner's key on its own document must survive.
*/
governProtected(owner: string, reader: string) {
setCurrentUser(owner);
// A public entity document, published as a shareable repo link.
const publicLink = getCaps().publishRepoLink(PUBLIC_PROBE);
setCurrentUser(reader);
// The reader was handed that link — which is all "public" means here.
getCaps().learn(publicLink);
},
/**
* Declare a bilateral owner↔reader connection (domain sharing act) the way
* two real sessions would: each side asserts from ITS OWN session, because
* sharing a key requires HOLDING it and `capFor` answers for the connected
* identity alone. Reader asserts first (nothing to share yet), then the owner
* asserts back — that second call is the one that finds a two-sided link and
* hands its protected documents' keys to the reader's inbox. Finally the
* reader reconnects and `connectedUser()` drains that inbox, which is where
* the key actually lands among what the reader holds.
*/
async connect(owner: string, reader: string) {
const reg = await import('../utils/storeRegistry');
// The reader's own account + inbox, provisioned from the READER's session
// so what belongs to it is filed under it.
setCurrentUser(reader);
await reg.ensureAccount(reader);
await reg.walletInbox(reader);
await declareConnections([owner], reader); // reader asserts owner
setCurrentUser(owner);
await declareConnections([reader], owner); // bilateral → owner shares its keys
setCurrentUser(reader);
await connectedUser(); // the reader drains its inbox → it now holds the key
},
/** Does the CURRENT user hold the public entity document's key — the only
* question the model can answer — regardless of the protected one? */
canReadPublicProbe() {
return getCaps().capFor(PUBLIC_PROBE) !== undefined;
},
// --- Stopgap multi-store validation (see brief_2026-06-15_shared-wallet-shim) ---
/**
* Create a fresh graph document via doc_create and mount it into a real
* useShape({graphs}) subscription (<SmokeProbe>). Returns the NURI.
* Validates: doc_create returns a usable graph NURI.
*/
async createSmokeDoc() {
const nuri = await docs.docCreate(session.session_id, 'Graph', 'data:graph', 'store', undefined);
setSmokeDoc(nuri);
return nuri;
},
/**
* Round-trip the sharedWalletShim through the wallet: create an account
* (3 docs + SPARQL INSERT), drop the cache, reload from the wallet via
* SPARQL SELECT. Validates: doc_create ×3 + shim sparql_update/query.
*/
async validateShim(identifier: string) {
const reg = await import('../utils/storeRegistry');
reg.resetRegistryCache();
const created = await reg.ensureAccount(identifier);
reg.resetRegistryCache();
// Re-read THIS account back from the wallet (there is no all-accounts
// enumeration any more — a directory is discovery, and discovery does not
// exist). `resolveAccount` reads without provisioning, which is exactly
// what a round-trip check needs.
const reloaded = await reg.resolveAccount(identifier);
return { created, reloaded };
},
/**
* Per-entity granularity + fan-out: 2 accounts, one event document each
* (via createEntityDoc → indexed), then mount a multi-graph useShape over
* both (<FanoutProbe>). Returns the two doc NURIs and the index listing.
* Validates: 1-doc-per-entity, index append/read, fan-out across N docs.
*/
async setupFanout() {
const reg = await import('../utils/storeRegistry');
reg.resetRegistryCache();
await reg.ensureAccount('@fan-a');
await reg.ensureAccount('@fan-b');
const docA = await reg.createEntityDoc('@fan-a', 'public');
const docB = await reg.createEntityDoc('@fan-b', 'public');
// The index-append (which makes docA/docB show up in the scope index) can
// lag behind createEntityDoc on the broker — poll until BOTH are listed
// (bounded) so the "index lists both docs" assertion isn't flaky. Each
// account's own index is read separately: there is no cross-account
// enumeration any more, and the fan-out under test is the READ over both
// documents, not the listing.
let listed: Nuri[] = [];
for (let i = 0; i < 12; i++) {
reg.resetRegistryCache();
const [a, b] = await Promise.all([
reg.listMyEntityDocs('@fan-a', 'public'),
reg.listMyEntityDocs('@fan-b', 'public'),
]);
listed = [...new Set([...a, ...b])];
if (listed.includes(docA) && listed.includes(docB)) break;
await new Promise(r => setTimeout(r, 1500));
}
setFanoutGraphs([docA, docB]);
return { docA, docB, listed };
},
// --- Public discovery: REMOVED ------------------------------------
// The two probes that lived here (publishPublicEventAs /
// discoverPublicEventsAs) exercised the SDK's global discovery index.
@@ -746,46 +457,6 @@ function ConnectedHarness() {
// served is @wip until Festipod publishes a directory document of its
// own — at which point the probes come back, reading the directory
// instead of an SDK index.
// --- T02.h GATING: protected native store openability -----------------
// Does the REAL protected store (`did:ng:${protected_store_id}`) open for
// ORM reads AND writes the same way private does? Private was chosen
// (decision_2026-03-17) precisely because it opened without RepoNotFound.
// Before switching the domain scope to protected, prove empirically that
// a write scoped to protectedNuri is READABLE back (round-trip). Mounting
// <ProtectedProbe> subscribes a useShape(protectedNuri) — that
// orm_start_graph call is what opens the repo in the verifier.
protectedNuri,
mountProtectedProbe() {
setProtectedActive(true);
},
/** Authoritative round-trip: SPARQL INSERT a marker triple into the
* protected store graph, then SPARQL SELECT it back — bypassing the ORM
* set entirely, so a RepoNotFound surfaces as a thrown error here. */
async protectedSparqlRoundTrip() {
if (!protectedNuri) throw new Error('no protected_store_id in session');
const subj = `did:ng:o:probe${Date.now().toString(36)}`;
const g = protectedNuri.replace(/^did:ng:/, 'did:ng:');
const insert = `INSERT DATA { GRAPH <${protectedNuri}> { <urn:probe:s> <urn:probe:p> "hit" } }`;
let insertError: string | null = null;
try {
await docs.sparqlUpdate(session.session_id, insert, protectedNuri);
} catch (e: any) {
insertError = String(e?.message ?? e);
}
void subj; void g;
let count = 0;
let queryError: string | null = null;
try {
const q = `SELECT (COUNT(*) AS ?n) WHERE { GRAPH <${protectedNuri}> { <urn:probe:s> <urn:probe:p> ?o } }`;
const res: any = await docs.sparqlQuery(session.session_id, q, undefined, protectedNuri);
const rows = Array.isArray(res) ? res : res?.results?.bindings ?? [];
count = parseInt(rows[0]?.n?.value ?? '0', 10) || 0;
} catch (e: any) {
queryError = String(e?.message ?? e);
}
return { insertError, queryError, count, protectedNuri };
},
};
console.log('[HarnessNG] Ready — events:', events.size, 'users:', users.size,
@@ -800,124 +471,10 @@ function ConnectedHarness() {
return (
<>
<div id="harness-status">{bridgeReady ? 'READY' : 'LOADING_SHAPES'}</div>
{filterDoc && <FilterProbe documentNuri={filterDoc} />}
{smokeDoc && <SmokeProbe docNuri={smokeDoc} />}
{fanoutGraphs.length > 0 && <FanoutProbe graphs={fanoutGraphs} />}
{protectedActive && protectedNuri && <ProtectedProbe protectedNuri={protectedNuri} />}
</>
);
}
// ============================================================================
// ProtectedProbe (T02.h gating) — subscribes an ORM set scoped to the REAL
// protected native store, so `orm_start_graph` opens that repo in the verifier
// (the same mechanism that made private work — decision_2026-03-17). Exposes
// window.__protected: an ORM add() + read-back, to prove the protected store
// round-trips writes the way private does (or surfaces RepoNotFound if not).
// ============================================================================
function ProtectedProbe({ protectedNuri }: { protectedNuri: string }) {
const set = useShape(FpParticipationShapeType, protectedNuri) as DeepSignalSet<FpParticipation>;
useEffect(() => {
(window as any).__protected = {
ready: true,
protectedNuri,
add() {
set.add({
'@graph': protectedNuri,
'@type': 'http://festipod.org/Participation',
'@id': '',
event: 'urn:protected:event',
user: 'urn:protected:user',
isConfirmed: true,
} as FpParticipation);
},
count() { return set.size; },
items() {
return [...set].map(p => ({ '@id': p['@id'], event: p.event, user: p.user }));
},
};
}, [set, protectedNuri]);
return null;
}
// ============================================================================
// FilterProbe — subscribes participations AFTER a ReadCap policy is active, so
// useShape returns the read-filtered VIEW. Exposes window.__readFilter.snapshot()
// (evaluated lazily → reflects the CURRENT user) for the @data scenario that
// validates the per-document read filter on the real ORM set.
// ============================================================================
function FilterProbe({ documentNuri }: { documentNuri: string }) {
const set = useShape(FpParticipationShapeType, documentNuri) as DeepSignalSet<FpParticipation>;
useEffect(() => {
(window as any).__readFilter = {
ready: true,
// Lazy: the filtered view reads the current user at access time, so calling
// snapshot() after setUser() reflects the new cap holder without remount.
snapshot: () => ({ count: set.size, users: [...set].map(p => p.user) }),
};
}, [set]);
return null;
}
// ============================================================================
// FanoutProbe — real useShape({graphs}) over SEVERAL entity documents.
// Exposes window.__fanout for the per-entity fan-out @data scenario.
// ============================================================================
function FanoutProbe({ graphs }: { graphs: string[] }) {
const set = useShape(FpEventShapeType, { graphs } as any) as DeepSignalSet<FpEvent>;
useEffect(() => {
(window as any).__fanout = {
ready: true,
graphs,
addEventTo(docNuri: string, title: string) {
set.add({
'@graph': docNuri,
'@type': 'http://festipod.org/Event',
'@id': '',
title,
participantCount: 1,
} as FpEvent);
},
count() { return set.size; },
titles() { return [...set].map(e => e.title); },
};
}, [set, graphs]);
return null;
}
// ============================================================================
// SmokeProbe — real useShape({graphs}) on a doc_create'd document.
// Exposes window.__smoke for the multi-store @data validation scenario.
// ============================================================================
function SmokeProbe({ docNuri }: { docNuri: string }) {
const set = useShape(FpParticipationShapeType, { graphs: [docNuri] } as any) as DeepSignalSet<FpParticipation>;
useEffect(() => {
(window as any).__smoke = {
ready: true,
docNuri,
add() {
set.add({
'@graph': docNuri,
'@type': 'http://festipod.org/Participation',
'@id': '',
event: 'urn:smoke:event',
user: 'urn:smoke:user',
isConfirmed: true,
} as FpParticipation);
},
count() { return set.size; },
items() {
return [...set].map(p => ({ '@id': p['@id'], event: p.event, user: p.user }));
},
};
}, [set, docNuri]);
return null;
}
// ============================================================================
// Bootstrap
// ============================================================================
+4 -35
View File
@@ -12,7 +12,6 @@
*/
import { Window } from 'happy-dom';
import React from 'react';
import { getScreen } from '../../screens/index';
import { LocalDataProvider } from '../context/FestipodDataContext';
import { RouterProvider } from '../../app/router';
@@ -104,37 +103,6 @@ export async function renderScreen(screenId: string, path?: string): Promise<Doc
return doc;
}
/**
* Render an arbitrary React element (a prop-driven component) into the same
* happy-dom container. Unlike {@link renderScreen}, this does NOT go through the
* screen registry or wrap in the data/router providers — use it for standalone,
* prop-driven components such as the AccessGateScreen (the access barrier), which
* take their state as props rather than reading it from context. Returns the
* rendered document for DOM assertions.
*/
export async function renderElement(element: React.ReactElement): Promise<Document> {
await ensureDomGlobals();
if (!window) throw new Error('DOM globals not installed');
if (root) {
root.unmount();
root = null;
}
const doc = window.document as unknown as Document;
doc.body.innerHTML = '<div id="root"></div>';
const container = doc.getElementById('root')!;
root = createRoot(container);
await new Promise<void>((resolve) => {
root.render(element);
setTimeout(resolve, 0);
});
return doc;
}
/**
* Convert a registry path with `:id` placeholders to a concrete URL using the
* first seed event/user when applicable. Tests can override via the explicit
@@ -154,9 +122,10 @@ function defaultPathFor(registryPath: string): string {
}
/**
* Set the happy-dom window's URL (so `window.location.search` reflects a given
* query string) BEFORE a subsequent render. Used to drive context that resolves
* state from the URL — e.g. AccountContext reads the `?id=` param. Idempotent:
* Set the happy-dom window's URL (so `window.location.search` / `.pathname`
* reflect a given URL) BEFORE a subsequent render. For a screen whose state comes
* from the URL rather than from props. No caller today — the screens under @ui
* take their route through renderScreen()'s `path` argument. Idempotent:
* installs the DOM globals first if needed.
*/
export async function setRenderUrl(url: string): Promise<void> {
+28 -30
View File
@@ -2,21 +2,21 @@
* connections (Festipod glue) — the app owns the relationship concept.
*
* "Connected" is a Festipod domain fact (an accepted, two-sided friendship), not
* something the data SDK models: the SDK models reading as key POSSESSION, so the
* only act available is `shareCap(cap, toInbox)` — hand ONE document's key to ONE
* recipient, addressed by their inbox. So the app keeps its own bilateral
* relationship registry here and, once a link is two-sided, SHARES the keys of its
* own protected documents with that neighbour. The app carries no access CHECK
* (that stays the SDK's job — see knowledge_trust-model); it only shares what
* follows from its own relationship graph.
* something the data SDK models: the SDK models reading as key POSSESSION, and
* giving someone that key is ONE act — `inbox.share(doc, toUser)`, naming the
* document and the person. So the app keeps its own bilateral relationship
* registry here and, once a link is two-sided, SHARES its own protected
* documents with that neighbour. The app carries no access CHECK (that stays the
* SDK's job — see knowledge_trust-model); it only shares what follows from its
* own relationship graph, and it never handles a key or an inbox address.
*
* A link between `a` and `b` is live only when BOTH `a → b` and `b → a` have been
* asserted. A reader who unilaterally self-declares a link to an owner gets
* nothing: the owner never asserted them back, so nothing is shared with them.
*/
import { capFor, shareCap } from '@ng-eventually/client/polyfill';
import { listMyEntityDocs, walletInbox } from './storeRegistry';
import { inbox } from '@ng-eventually/polyfill';
import { listMyEntityDocs } from './storeRegistry';
/** Accumulates directed assertions and exposes the bilateral neighbourhood. */
class RelationshipRegistry {
@@ -60,22 +60,25 @@ const registry = new RelationshipRegistry();
/**
* Declare the connections a session asserts, as `self`, to each id in `peers`,
* then share what follows. For every bilateral link (both sides asserted), the
* session shares the key of each of ITS OWN protected documents
* (`listMyEntityDocs(self, 'protected')` + `capFor`) into that neighbour's inbox
* (`walletInbox(neighbour)` + `shareCap`). Re-callable whenever the relationship
* graph changes.
* session shares each of ITS OWN protected documents with that neighbour —
* `inbox.share(doc, neighbour)`, one act naming the document and the person.
* Re-callable whenever the relationship graph changes.
*
* `self` is the id of the asserting identity (its normalized-id key, the same key
* its documents are recorded under). A session only ever asserts its own side —
* and now it can only SHARE its own side too: sharing a key requires holding it,
* and `capFor` answers for the connected identity alone. Where the previous
* version re-derived grants for every asserter (possible while reading was an ACL
* one process could edit for everyone), each identity now shares its own
* documents from its own session.
* `self` is the id of the asserting identity; a session only ever asserts — and
* only ever shares — its own side. Sharing requires holding the document, and a
* session holds only what it created.
*
* Idempotent in EFFECT, not in traffic: `addLink` on the receiving side ignores a
* key it already holds, so re-sharing changes nothing for the recipient — but each
* call appends a fresh deposit to their inbox document (see the caller's note).
* A NEIGHBOUR MUST BE SOMEONE WHO EXISTS: `inbox.share` names a person, and it
* refuses a recipient nobody has signed in as rather than creating them. So the
* keys handed here must come from the relationship graph of REAL accounts — a
* key derived from a profile that is only a fixture names nobody, and the share
* rejects. Nothing downstream waits on this call (the caller is
* fire-and-forget), so such a rejection surfaces as a logged error, never as a
* silent grant.
*
* Idempotent in EFFECT, not in traffic: the recipient applies a key it already
* holds to no effect, so re-sharing changes nothing for them — but each call is
* a fresh deposit (see the caller's note).
*/
export async function declareConnections(peers: Iterable<string>, self: string): Promise<void> {
if (!self) return;
@@ -85,16 +88,11 @@ export async function declareConnections(peers: Iterable<string>, self: string):
if (neighbours.size === 0) return;
// My own protected documents. Resolved once: the set is the same for every
// neighbour, and each resolution is a broker read.
const myDocs = await listMyEntityDocs(self, 'protected');
const myDocs = await listMyEntityDocs('protected');
if (myDocs.length === 0) return;
for (const neighbour of neighbours) {
const inbox = await walletInbox(neighbour);
for (const doc of myDocs) {
const cap = capFor(doc);
// No key held → nothing to share. A document I cannot read is not mine to
// hand over, and no key is ever derived from a bare reference.
if (!cap) continue;
await shareCap(cap, inbox);
await inbox.share(doc, neighbour);
}
}
}
+61
View File
@@ -0,0 +1,61 @@
/**
* currentPrincipal — WHO the session signed in as, as `ensureIdentity()` hands it
* back.
*
* Signing in is one await (`ensureIdentity()`), and it now RETURNS the identity.
* That value is the app's only upstream answer to "who am I": it is known the
* moment the barrier settles, before any document has been read.
*
* WHAT IT IS FOR — display and logging, nothing else. **No call takes it**: a
* session belongs to one user, so placement is named by scope alone
* (`createEntityDoc(scope)`, `listMyEntityDocs(scope)`). Handing this value back
* to the data layer would re-create the parameter the surface deliberately
* removed, so it must never travel into an SDK call.
*
* 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.
*
* WHY A MODULE STORE and not a React context: `AuthGate` — the component that
* makes the await — is mounted INSIDE `FestipodDataProvider`, so a context it
* published would be invisible to the very consumer that needs it. A plain
* module singleton observed with `useSyncExternalStore` is independent of
* provider order, exactly like `data/pendingQueries.ts`.
*/
import { useSyncExternalStore } from 'react';
import type { PrincipalId } from '@ng-eventually/polyfill';
/** `''` means "signing in has not settled yet" — never "no identity". */
let principal: string = '';
const listeners = new Set<() => void>();
/** Record the identity `ensureIdentity()` returned. Idempotent per value. */
export function setCurrentPrincipal(id: PrincipalId): void {
if (principal === id) return;
principal = id;
for (const l of listeners) l();
}
/** The signed-in principal, or `''` while the one identity await is pending. */
export function getCurrentPrincipal(): string {
return principal;
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/**
* Subscribe a component to the signed-in principal. The snapshot is a primitive
* string, stable between real changes — no render loop.
*/
export function useCurrentPrincipal(): string {
return useSyncExternalStore(subscribe, getCurrentPrincipal, getCurrentPrincipal);
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Identifier normalization — Festipod's OWN handle → key mapping.
*
* Nothing here is lent to the SDK. Normalizing identities is the SDK's own affair
* (`@Alice`, `alice ` and `ALICE` are one person to it, and it needs no hook from
* us to say so). What remains is a Festipod comparison: a profile's `username` is
* an app field, typed by a human, and the app matches it against the signed-in
* principal and against peer keys in its own relationship graph. That is the sole
* remaining caller (`FestipodDataContext`).
*/
/** Trim, strip leading `@`, lowercase — the canonical form of a handle. */
export function normalizeIdentifier(identifier: string | null | undefined): string {
return (identifier ?? '').trim().replace(/^@+/, '').toLowerCase();
}
+25 -26
View File
@@ -11,8 +11,8 @@
* them to the live subscription set (reactivity).
*/
import type { Nuri } from '@ng-eventually/client';
import { normalizeIdentifier } from '../context/AccountContext';
import type { Nuri } from '@ng-eventually/polyfill';
import { openDocumentInbox } from './storeRegistry';
import {
seedEvents,
seedUsers,
@@ -21,7 +21,8 @@ import { writeEntity, ENTITY_TYPE, str, int, flt, bool } from '../data/entityWri
/** Scope of a seed entity + how to create its own document (SDK create). */
export type Scope = 'public' | 'protected' | 'private';
export type CreateEntityDoc = (owner: string, scope: Scope) => Promise<Nuri>;
/** Placement is named by SCOPE ALONE — the session that seeds owns what it seeds. */
export type CreateEntityDoc = (scope: Scope) => Promise<Nuri>;
export interface BootstrapResult {
seeded: boolean;
@@ -43,7 +44,6 @@ export interface BootstrapResult {
export async function bootstrapWallet(
walletHasData: boolean,
createEntityDoc: CreateEntityDoc,
owner?: string,
): Promise<BootstrapResult> {
const createdDocs: { public: Nuri[]; protected: Nuri[] } = { public: [], protected: [] };
// Already has data → returning user, nothing to seed
@@ -54,20 +54,13 @@ export async function bootstrapWallet(
console.log('[Bootstrap] First time for this wallet — seeding per-entity docs...');
// OWNER: all seed entities are owned by the SINGLE seed owner account — the
// CURRENT logged-in account (`owner`), so "load test data into MY wallet" makes
// the current user the owner. This matters for PROTECTED entities (seed user
// profiles, participations): per-document isolation grants a protected doc's
// ReadCap to its OWNER (+ connections), so if the seed owned them as someone
// ELSE (e.g. the fixture's `mariedupont`) they'd be correctly HIDDEN from the
// current fresh-scenario user and never round-trip. Owning them as the current
// user makes them readable. PUBLIC events are world-readable regardless of owner.
// The seed users are FIXTURES, not real login accounts — minting a full owner
// account per seed user would be dozens of broker round-trips (unusably slow)
// with no product meaning. One account (the current user) owns them; each entity
// is still ITS OWN document (per-document isolation unchanged — only the cap
// OWNER is shared). Falls back to the fixture username when no login is present.
const seedOwner = owner ?? (seedUsers[0] ? normalizeIdentifier(seedUsers[0].username) : 'seed');
// OWNERSHIP: "load test data into MY wallet" — every seeded entity is created
// in a SCOPE of the signed-in session, which therefore owns it. That is what
// makes the PROTECTED fixtures (user profiles, participations) round-trip:
// reading is possession of the key, and the creator holds what it created.
// The seed users are FIXTURES, not accounts anyone signs in as — they exist to
// be read, never to be addressed as a person. PUBLIC events are readable by
// whoever reaches them, ownership notwithstanding.
// SEED FOOTPRINT (perf). Each entity is its OWN document, and `docCreate` is a
// round-trip that does not overlap with the next one, so the seed cost grows
@@ -83,16 +76,18 @@ export async function bootstrapWallet(
const SEED_USER_LIMIT = 3;
const usersToSeed = seedUsers.slice(0, SEED_USER_LIMIT);
// Establish the owner account ONCE, serially, BEFORE any create: the first
// `createEntityDoc(seedOwner, …)` creates the owner account and
// caches it, so later concurrent calls don't race to re-create the account.
const firstUserGraph = await createEntityDoc(seedOwner, 'protected');
// Serialize the FIRST create, then fan out: it warms whatever the scope needs
// to exist before a document can be filed in it, so the concurrent creates that
// follow don't race to establish the same thing twice. A creation that cannot
// be recorded THROWS (it never returns a reference that would read empty
// forever), so a failure here aborts the seed instead of half-seeding it.
const firstUserGraph = await createEntityDoc('protected');
// Users — one PROTECTED document each. The written subject IRI is the entity's
// stable `@id`, kept in the id map.
const userIdMap = new Map<string, string>();
await Promise.all(usersToSeed.map(async (u, i) => {
const graph = i === 0 ? firstUserGraph : await createEntityDoc(seedOwner, 'protected');
const graph = i === 0 ? firstUserGraph : await createEntityDoc('protected');
createdDocs.protected.push(graph);
const id = await writeEntity(graph, ENTITY_TYPE.user, {
name: str(u.name), initials: str(u.initials), username: str(u.username),
@@ -105,7 +100,11 @@ export async function bootstrapWallet(
// Events — one PUBLIC document each (all of them: looked up by title in @data).
const eventIdMap = new Map<string, string>();
await Promise.all(seedEvents.map(async (e) => {
const graph = await createEntityDoc(seedOwner, 'public');
const graph = await createEntityDoc('public');
// An event is a document people SIGN UP TO, so its owner opens its inbox —
// a document only has one if its owner did. Without this, a registrant's
// deposit has nothing to reach. Only events get one (not every entity).
await openDocumentInbox(graph);
createdDocs.public.push(graph);
const id = await writeEntity(graph, ENTITY_TYPE.event, {
title: str(e.title), description: str(e.description), date: str(e.date),
@@ -115,8 +114,8 @@ export async function bootstrapWallet(
});
eventIdMap.set(e.id, id);
// The seeded event is NOT announced anywhere: there is no discovery index to
// submit it to. A fresh virtual user therefore does not see the seeded events
// — it sees its own. Restoring that requires the directory document.
// submit it to. Someone who did not run this seed therefore does not see these
// events — they see their own. Restoring that requires the directory document.
}));
console.log('[Bootstrap] Seeded', eventIdMap.size, 'events (participations created live)');
+65 -32
View File
@@ -1,14 +1,21 @@
// Injection point — the ONLY app module that imports the real @ng-org SDK, to
// inject it into @ng-eventually/client. Every other Festipod module gets
// its NextGraph surface from @ng-eventually/client. Removed at migration.
// inject it into @ng-eventually/polyfill. Every other Festipod module gets its
// NextGraph surface from @ng-eventually/polyfill. Removed at migration.
import { ng as realNg, init as realInit } from "@ng-org/web";
import type { NG } from "@ng-eventually/client";
import type { NG } from "@ng-eventually/polyfill";
import { initNg as realInitNg } from "@ng-org/orm";
import { useShape as realUseShape } from "@ng-org/orm/react";
import { configure } from "@ng-eventually/client/polyfill";
// SDK-shaped surface used by ngSession itself — taken from the lib, not @ng-org.
import { ng, init as initNgWeb, initNg as initNgSignals } from "@ng-eventually/client";
// SDK-shaped surface used by ngSession itself — taken from the SDK, not @ng-org.
import { configure, ng, init as initNgWeb, initNg as initNgSignals } from "@ng-eventually/polyfill";
// The deployment's wallet — ONE copy of these values in the app, shared with the
// access barrier (modules/auth re-exports this module).
import {
SHARED_WALLET_FILE_URL,
SHARED_WALLET_PASSWORD,
WALLET_IMPORT_URL,
hasSharedWallet,
} from "./sharedWallet";
// DIAGNOSTIC (shared-wallet isolation): the SDK access log SEES every read/write
// prefixed by the active identity — the way to catch a doc read under the wrong
@@ -23,13 +30,6 @@ function accessLogEnabled(): boolean {
return true;
}
const __accessLogOn = accessLogEnabled();
console.log("[NG session] access-log:", __accessLogOn ? "ON" : "OFF (localStorage festipod.debug.accessLog=0)");
configure({
ng: realNg, useShape: realUseShape, init: realInit, initNg: realInitNg,
debugAccessLog: __accessLogOn,
});
export let session: NextGraphSession | undefined;
let resolveSessionPromise: (
@@ -44,15 +44,47 @@ export let sessionPromise: Promise<NextGraphSession> = new Promise(
}
);
// --- The ONE bootstrap call -------------------------------------------------
// `configure` is the single bootstrap entry, and it now injects ONE thing: the
// real @ng-org surface plus what this deployment hands its users. It carries no
// consumer wiring any more — THE SESSION IS THE SDK'S, not ours. We never build
// one and no call takes one: the SDK captures the session itself when `init` (its
// own, called below) fires, and it normalizes identities itself. The app reads
// the `session_id` off the same `init` event, for the `docs` primitives only.
const __accessLogOn = accessLogEnabled();
console.log("[NG session] access-log:", __accessLogOn ? "ON" : "OFF (localStorage festipod.debug.accessLog=0)");
console.log("[NG session] shared wallet:", hasSharedWallet() ? "configured" : "absent");
configure({
ng: realNg, useShape: realUseShape, init: realInit, initNg: realInitNg,
debugAccessLog: __accessLogOn,
// What THIS deployment hands its users: the wallet file it serves and the
// password it publishes. The SDK reads no environment variable — the app
// resolves the values (build-time `define` or the runtime config endpoint)
// and passes them. No shared wallet configured → nothing to pass.
sharedWallet: hasSharedWallet()
? {
fileUrl: SHARED_WALLET_FILE_URL,
password: SHARED_WALLET_PASSWORD,
importUrl: WALLET_IMPORT_URL,
}
: undefined,
});
let initPromise: Promise<void> | null = null;
/**
* Register the initNgWeb callback. Idempotent — returns the same promise on repeated calls.
* Does NOT trigger login by itself. Call login() separately to open the wallet login page.
* START THE SESSION — the SDK's own `init`, which is the ONLY way a session comes
* into being. It captures the session itself and then calls this callback with the
* same event untouched; the app reads `session_id` off it, because the `docs`
* primitives are the one part of the surface that still names a session.
*
* ORDER IS CONTRACTUAL: this call must have happened before anything awaits
* `ensureIdentity()`, which throws otherwise. Idempotent — repeated calls return
* the same promise, so every entry point may (and should) call it before awaiting.
*/
export function init(): Promise<void> {
export function startNgSession(): Promise<void> {
if (initPromise) return initPromise;
console.log('[NG session] init() called — registering callback');
console.log('[NG session] startNgSession() — registering the init callback');
initPromise = (initNgWeb(
async (event: any) => {
session = event.session;
@@ -70,27 +102,25 @@ export function init(): Promise<void> {
return initPromise;
}
/**
* Trigger the wallet login page. Must call init() first.
*/
export async function login() {
console.log('[NG session] login() called — opening wallet login');
await ng.login();
}
// NO `login()` HERE ANY MORE. Opening the wallet page is not something the app
// drives: `ensureIdentity()` mounts its own barrier and owns the round-trip that
// follows. Nothing called this, and the surface publishes no such call.
/**
* REAL NextGraph logout — stops the session of the SHARED wallet.
*
* STOPGAP: must stay HIDDEN (Settings/debug only). The everyday "Déconnexion"
* is the FAUX one (AccountContext.logout, clears the identifier only). Calling
* this forces a new broker redirect on the next access — see
* STOPGAP: must stay HIDDEN (Settings/debug only). Calling this forces a new
* broker redirect on the next access — see
* decision_2026-06-15_shared-wallet-login-flow.
*/
export async function logoutNg(): Promise<void> {
const userId = session && (session as Record<string, unknown>).user;
if (!userId) return;
// `session.user` comes through the event's open index signature, so narrow it
// rather than assert it: `session_stop` names a user id, and there is nothing
// to stop if the session never carried one.
const userId = session?.user;
if (typeof userId !== 'string' || !userId) return;
try {
await (ng as unknown as { session_stop: (u: unknown) => Promise<void> }).session_stop(userId);
await ng.session_stop(userId);
console.log('[NG session] session_stop done');
} catch (error) {
console.error('[NG session] logout error:', error);
@@ -98,11 +128,14 @@ export async function logoutNg(): Promise<void> {
}
export interface NextGraphSession {
// `NG` IS the type of the SDK object (re-exported by @ng-eventually/client) —
// `NG` IS the type of the SDK object (re-exported by @ng-eventually/polyfill) —
// not a value whose type we could take. `typeof NG` was a type-level error that
// the broken typecheck gate hid.
ng: NG;
session_id: string;
// RELAYED, never converted: the `docs` primitives declare `string | number`
// because that is what a session carries, and handing them a stringified one
// fails. Declaring it `string` here would invite exactly that coercion.
session_id: string | number;
protected_store_id: string;
private_store_id: string;
public_store_id: string;
+39
View File
@@ -0,0 +1,39 @@
/**
* Shared wallet material for the staging stopgap — the deployment's wallet.
*
* What this deployment HAS to hand over: the wallet file it serves, the password
* it publishes, and where a wallet is imported. The app does not use these to
* render anything — it passes all three to the SDK through the ONE `configure()`
* bootstrap (`shared/utils/ngSession`), which owns what a user sees while signing
* in. This module is the single consumer-facing copy of that material.
*
* The correct primitive is the **wallet FILE** (.ngw), NOT a TextCode: a
* TextCode is a transient device-to-device transfer (5 min, source device
* online, single use) — useless to embed. A wallet file is STATIC and reusable.
*
* ZERO-SECURITY shared credential (friendly users) → embedding the file +
* password is consistent with the posture.
*
* `build.ts` copies the file (from FESTIPOD_SHARED_WALLET_FILE) to the bundle as
* `/shared-wallet.ngw`, and `define`s the password global from
* FESTIPOD_SHARED_WALLET_PASSWORD. Empty password → no shared wallet configured
* → nothing is passed to `configure`.
*/
// Build-injected global (not `process.env`, absent in the browser); any path
// that doesn't inject it reads `undefined` → '' safely (no ReferenceError).
declare global {
// eslint-disable-next-line no-var
var __FESTIPOD_SHARED_WALLET_PASSWORD__: string | undefined;
}
export const SHARED_WALLET_PASSWORD: string = globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__ ?? '';
/** URL of the shared wallet file in the bundle (copied by build.ts). */
export const SHARED_WALLET_FILE_URL = '/shared-wallet.ngw';
/** Standalone NextGraph wallet app — where the import actually happens. */
export const WALLET_IMPORT_URL = 'https://nextgraph.eu/#/wallet/login';
/** Whether this deployment has a shared wallet to hand the SDK. */
export const hasSharedWallet = (): boolean => SHARED_WALLET_PASSWORD.trim().length > 0;
+23 -57
View File
@@ -1,18 +1,16 @@
/**
* storeRegistry (Festipod glue) — the lib owns placement; the app maps
* storeRegistry (Festipod glue) — the SDK owns placement; the app maps
* entity → scope. This file keeps ONLY the Festipod domain mapping (entity kind
* → scope) and injects the consumer wiring the lib needs (session + identifier
* normalization) via `configureStoreRegistry(...)`. The app re-exports the lib
* surface so existing callers stay unchanged.
* → scope) and re-exports the SDK registry surface so existing callers stay
* unchanged. The consumer wiring the SDK needs (session + id normalization) is
* declared in the ONE `configure` call, in `./ngSession`.
*/
import {
storeRegistry as libStoreRegistry,
type AccountRecord as LibAccountRecord,
} from '@ng-eventually/client';
import { configureStoreRegistry } from '@ng-eventually/client/polyfill';
import { sessionPromise } from './ngSession';
import { normalizeIdentifier } from '../context/AccountContext';
// Side-effect import: `configure` runs at ngSession's module evaluation, and it
// must have run before ANY registry call. Importing it here makes that ordering
// a fact of the module graph rather than a convention.
import './ngSession';
import { storeRegistry as sdkStoreRegistry } from '@ng-eventually/polyfill';
export type Scope = 'public' | 'protected' | 'private';
@@ -34,54 +32,22 @@ export function entityScope(kind: EntityKind): Scope {
}
}
// --- Consumer wiring injected into the lib's storeRegistry ---
// The lib is Festipod-agnostic: it reaches the session and the identity-id
// normalization through these injected deps. Idempotent module-load side effect
// (the app imports storeRegistry before any registry call).
configureStoreRegistry({
getSession: async () => {
const session = await sessionPromise;
// Sanctioned injection point: the session is handed to the lib HERE and
// nowhere else. The lib owns placement and resolves scope internally; the
// rest of the app speaks only in logical scopes.
return {
sessionId: session.session_id,
privateStoreId: session.private_store_id,
protectedStoreId: session.protected_store_id,
publicStoreId: session.public_store_id,
};
},
// The app maps its identifier handle to the identity id the lib keys on.
normalizeId: normalizeIdentifier,
// Pointer micro-guard (real broker): the account records now live in a
// subscribable doc-shim reached through a well-known write-once pointer triple in
// the store-root. The account read is barrier-authoritative (no account retry).
// The only residual store-root sync-lag is the pointer read itself — this bounded
// guard re-reads JUST that one triple a few times on a cold reconnect. It can never
// provision or fork an account. Bounded (never an open-ended broker poll).
pointerGuard: { attempts: 8, baseMs: 150, maxStepMs: 2000 },
});
// --- Re-export the lib's account record + registry surface (unchanged API) ---
export type AccountRecord = LibAccountRecord;
// --- Re-export the SDK's registry surface -----------------------------------
// Placement is named by SCOPE ALONE: a session belongs to one user, so no call
// here takes an identity. "My documents" means the signed-in session's, and the
// app has no way — and no need — to name whose they are.
export const {
ensureAccount,
resolveAccount,
resolveWriteGraph,
listMyEntityDocs,
resetRegistryCache,
// SDK-shaped scope resolvers — the app asks by scope, the lib resolves
// placement (no store-id ever crosses the boundary).
// SDK-shaped scope resolvers — the app asks by scope, the SDK resolves
// placement (no store id ever crosses the boundary).
resolveScopeGraph,
// Inboxes — an inbox BELONGS to someone: `walletInbox(id)` is an identity's own
// inbox, `documentInbox(doc)` the inbox of a document its owner holds. There is
// no inbox common to every wallet any more.
walletInbox,
documentInbox,
// Per-entity document creation. The lib itself files the creator's ReadCap on
// create (and publishes the repo link for a `public` one), so the app declares
// NO cap policy here: reading is possession, and the creator holds what it
// created. Re-exported straight through so callers stay unchanged.
// A document only HAS an inbox if its owner opened one. The app opens one on
// the documents meant to RECEIVE deposits (its events), and the address this
// returns is what the owner reads and watches.
openDocumentInbox,
// Per-entity document creation. The SDK itself files the creator's key on
// create, so the app declares NO access policy here: reading is possession,
// and the creator holds what it created.
createEntityDoc,
} = libStoreRegistry;
} = sdkStoreRegistry;