Migrate Festipod onto the rebuilt @ng-eventually/client surface
The SDK was rebuilt: reading is possession instead of an ACL, `Nuri` and `ReadCap` are template literal types, the cross-account fan-out is gone, and so is the global discovery index. Repair the typecheck gate FIRST — it was checking nothing. Under TypeScript 6 the deprecated `baseUrl` is reported as an ERROR that aborts compilation, so `tsc --noEmit` exited 0 having verified nothing, behind a single line that reads like a harmless warning. Dropping `baseUrl` (paths resolve relative to the file since 4.4) makes the gate real again — and it immediately surfaced 33 errors, three of which had been dormant for a long time. Types: 18 sites fixed AT THE SOURCE — the functions that produce a NURI now return `Nuri` — with `isNuri` guards only at genuine boundaries (an `@id` read back from a document, an argument coming from a Cucumber step). No cast, no `@ts-ignore`: silencing the compiler here would have removed the very guarantee the new types provide. Capabilities: the ACL is gone. `grantRead`/`protectedDocsOf`/`canRead`/ `makePublic` give way to `capFor`/`shareCap`/`publishRepoLink`, and `open` loses its `owner` argument. `declareConnections` now shares the caps of its OWN protected documents to each neighbour's wallet inbox. Discovery is REMOVED, not postponed: there is no discovery in the target model, a reader reaches a document only by following a link it was given. The module and its call sites are gone; the scenario is suspended with a comment saying what will bring it back — a Festipod DIRECTORY document, whose link the app knows. Kept rather than deleted: the product need has not gone away. Verification, and a correction to how it was measured. The @data baseline (20/22) had been taken on a bloated test wallet: 93 MB against a threshold documented around 99 MB, with the run stretching from 18 to 23 minutes. Restarting from a fresh profile drops it to 9m37 and turns BOTH baseline failures green — including the cold-reconnection one, which confirms the SDK's claim that a fresh session reads its own documents back with nothing re-declared. So the reference itself was degraded, on both sides of the comparison. Real state: typecheck 0, @ui 7/7, @data 20/21. The single failure is understood and left standing: the protected-connections probe reads the protected STORE document as a stand-in for an entity. Sharing a store cap would hand over its entire contents, present and future — precisely the gesture the model refuses. The scenario's own title says "the protected ENTITY"; the probe is what took the shortcut, and it is what has to change.
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
# language: fr
|
||||
@EVENT @priority-1
|
||||
# SUSPENDU (@wip) le 2026-08-03 — l'index global de découverte a été RETIRÉ du SDK :
|
||||
# il n'y a pas de découverte, on ne joint un document qu'en suivant un lien reçu.
|
||||
# Ce scénario décrit donc une capacité qui n'existe plus telle quelle. Il est
|
||||
# conservé, non supprimé : le besoin produit demeure, et il sera réalisé par un
|
||||
# ANNUAIRE Festipod — un document public dont l'app connaît le lien, alimenté par
|
||||
# dépôt et matérialisé par son curateur. À ce moment-là ce scénario est réécrit
|
||||
# sur l'annuaire et redevient actif.
|
||||
@EVENT @priority-1 @wip
|
||||
Fonctionnalité: Découverte publique via l'index global
|
||||
En tant qu'utilisateur
|
||||
Je veux découvrir les événements publics des autres comptes sans être connecté
|
||||
|
||||
@@ -81,7 +81,8 @@ Then('l\'événement {string} finit par apparaître sur la page fraîche A en la
|
||||
const titles = await readHome();
|
||||
if (titles.includes(title)) { appearedAtMs = elapsed; break; }
|
||||
// At each reload mark, do a FULL reload → new NgDataProvider mount → new barrier.
|
||||
if (reloadIdx < reloadAtMs.length && elapsed >= reloadAtMs[reloadIdx]) {
|
||||
const reloadMark = reloadAtMs[reloadIdx];
|
||||
if (reloadMark !== undefined && elapsed >= reloadMark) {
|
||||
reloadIdx++;
|
||||
console.log(`[LongPoll] t=${elapsed}ms still ABSENT — forcing a full reload (#${reloadIdx}) to re-attempt the barrier…`);
|
||||
try {
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
countUserParticipations,
|
||||
canonicalEventId,
|
||||
} from '../data/registration';
|
||||
import { inbox } from '@ng-eventually/client';
|
||||
import { inbox, isNuri } from '@ng-eventually/client';
|
||||
import type { Nuri } from '@ng-eventually/client';
|
||||
import {
|
||||
CURRENT_USER_ID,
|
||||
seedEvents,
|
||||
@@ -31,11 +32,10 @@ import {
|
||||
import { useNextGraph } from './NextGraphContext';
|
||||
import { useAccount, normalizeIdentifier } from './AccountContext';
|
||||
// Relationship is a Festipod concept: the app keeps its own bilateral registry
|
||||
// and hands the SDK only directed read grants (see shared/utils/connections).
|
||||
// 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 { submitEventToIndex } from '../data/discovery';
|
||||
import { useShapeQuery } from '../data/useShapeQuery';
|
||||
import { adaptEvents, adaptUsers, adaptParticipations } from '../data/shapeAdapters';
|
||||
import {
|
||||
@@ -418,7 +418,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
// event docs). Each such NURI IS the event `@id` (writeEntity uses the doc NURI
|
||||
// as the subject). The owner-materializer subscribes to each owned event's inbox
|
||||
// and writes `participantCount` on THAT (owned) doc — never on someone else's.
|
||||
const [ownedEventIds, setOwnedEventIds] = useState<string[]>([]);
|
||||
const [ownedEventIds, setOwnedEventIds] = useState<Nuri[]>([]);
|
||||
|
||||
// Resolve the CURRENT identity's owned public event docs for the materializer
|
||||
// ONLY (decoupled from the read — `watchShape` resolves reads itself). Bounded to
|
||||
@@ -558,34 +558,37 @@ function useNgData(): FestipodDataContextValue {
|
||||
// both would materialize + count-write the same event twice. Keep ONE real NURI
|
||||
// per canonical id as the write/anchor target (the count is written on a live
|
||||
// doc NURI — never a stripped id). See `canonicalEventId` in registration.ts.
|
||||
const ownedKey = React.useMemo(() => {
|
||||
const byCanon = new Map<string, string>();
|
||||
const ownedEvents = React.useMemo<Nuri[]>(() => {
|
||||
const byCanon = new Map<string, Nuri>();
|
||||
for (const nuri of ownedEventIds) {
|
||||
const c = canonicalEventId(nuri);
|
||||
if (!byCanon.has(c)) byCanon.set(c, nuri);
|
||||
}
|
||||
return [...byCanon.values()].sort().join('|');
|
||||
return [...byCanon.values()].sort();
|
||||
}, [ownedEventIds]);
|
||||
// Primitive identity of the set above — the effect's dependency (an array is a
|
||||
// new reference on every render).
|
||||
const ownedKey = ownedEvents.join('|');
|
||||
// Last count written per owned event, so we only persist a genuine change.
|
||||
const materializedCountRef = useRef<Map<string, number>>(new Map());
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
const owned = ownedKey ? ownedKey.split('|') : [];
|
||||
const owned = ownedEvents;
|
||||
if (owned.length === 0) return;
|
||||
let cancelled = false;
|
||||
|
||||
const materialize = async (trigger: string) => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
// Resolve the (shared) inbox anchor once; each owned event filters its own
|
||||
// deposits inside `materializeAttendance` / `readRegistrationNotifications`.
|
||||
const targetInbox = await hostInboxNuri('');
|
||||
console.log(
|
||||
`${logPrefix} owner participation materialize START (trigger=${trigger}) — ${owned.length} owned ` +
|
||||
`event(s), inbox=${targetInbox}`,
|
||||
`event(s)`,
|
||||
);
|
||||
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);
|
||||
// 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
|
||||
@@ -674,15 +677,18 @@ function useNgData(): FestipodDataContextValue {
|
||||
// that pushes) — no polling. Re-materialize on each so the owner's own session
|
||||
// stays live when a deposit does push. Cross-session convergence does NOT rely on
|
||||
// this (it relies on (A) at the owner's next connection); this only sharpens the
|
||||
// same-session/live case. The inbox anchor is resolved async, so wire the watch
|
||||
// inside an IIFE and stash the unsubscribe for cleanup.
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
// same-session/live case. One watch PER owned event — each event has its OWN
|
||||
// inbox document — resolved async, so wire them inside an IIFE and stash the
|
||||
// unsubscribes for cleanup.
|
||||
const unsubscribes: Array<() => void> = [];
|
||||
(async () => {
|
||||
const targetInbox = await hostInboxNuri('');
|
||||
if (cancelled) return;
|
||||
unsubscribe = inbox.watch(targetInbox, () => void materialize('inbox-push'));
|
||||
for (const evId of owned) {
|
||||
const targetInbox = await hostInboxNuri(evId);
|
||||
if (cancelled) return;
|
||||
unsubscribes.push(inbox.watch(targetInbox, () => void materialize('inbox-push')));
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; if (unsubscribe) unsubscribe(); };
|
||||
return () => { cancelled = true; for (const stop of unsubscribes) stop(); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, ownedKey]);
|
||||
|
||||
@@ -714,7 +720,10 @@ function useNgData(): FestipodDataContextValue {
|
||||
.map(f => (f.userId === currentUserId ? f.friendId : f.userId))
|
||||
.map(idKeyOf)
|
||||
.filter((k): k is string => !!k);
|
||||
declareConnections(myPeers, selfKey);
|
||||
// Sharing is I/O now (a key handed to each neighbour's inbox), so it is
|
||||
// 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]);
|
||||
|
||||
const queries = buildQueries(
|
||||
@@ -777,14 +786,11 @@ function useNgData(): FestipodDataContextValue {
|
||||
// non-public doc. Best-effort: a failed submission must not roll back a
|
||||
// successful event creation.
|
||||
if (addedEvent) {
|
||||
// Submitter is bound to the current identity by the SDK (it is not
|
||||
// caller-supplied) — pass `null` for an anonymous submission (discovery
|
||||
// needs no author). Avoids handing a principal the SDK would reject as a
|
||||
// spoof (the app's user IRI differs from the declared identity key).
|
||||
submitEventToIndex(
|
||||
{ doc: eventGraph, id: addedEvent["@id"], title: event.title },
|
||||
null,
|
||||
).catch(err => console.error(`${logPrefix} submit event to index failed:`, err));
|
||||
// NO index submission any more. There is no discovery: a reader reaches a
|
||||
// document only by FOLLOWING A LINK it was given. Announcing a public event
|
||||
// beyond its creator is therefore a Festipod-level concern (a directory
|
||||
// document whose link the app knows), not an SDK call — see the directory
|
||||
// work. Until it lands, a created event is reachable by its creator only.
|
||||
}
|
||||
return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` };
|
||||
}, [currentUserId, identifier]);
|
||||
@@ -795,6 +801,13 @@ 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;
|
||||
}
|
||||
const graph = id;
|
||||
const persists: Promise<void>[] = [];
|
||||
if (updates.participantCount !== undefined) {
|
||||
@@ -870,6 +883,13 @@ 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);
|
||||
// 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.
|
||||
@@ -909,7 +929,13 @@ 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 {
|
||||
@@ -953,6 +979,11 @@ 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);
|
||||
// Carry the join uid when this session minted it (precise cancellation);
|
||||
// otherwise the owner falls back to (eventId, userId) matching.
|
||||
@@ -991,7 +1022,13 @@ 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)));
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* Discovery domain glue — the FESTIPOD interpretation layered on top of the
|
||||
* GENERIC `@ng-eventually/client` `discovery` surface (the SDK's global
|
||||
* discovery index).
|
||||
*
|
||||
* The SDK owns the discovery MECHANISM entirely: how the global index is stored,
|
||||
* who hosts it, how a submission is materialized. The app treats the SDK as a
|
||||
* finished NextGraph SDK — discovery is simply "read the global index"; making a
|
||||
* public event discoverable is "submit its reference to the index". The app
|
||||
* holds NO document NURI of the index, no store id, and knows nothing of how the
|
||||
* index is owned or curated.
|
||||
*
|
||||
* THIS module supplies only the Festipod domain:
|
||||
* - the shape of the reference deposited into the index (`EventIndexRef`),
|
||||
* - `submitEventToIndex` — make a public event discoverable (an SDK act),
|
||||
* - `readDiscoveredEvents` — the discovered event references (an SDK read).
|
||||
*
|
||||
* Importable by `shared/` and by domain modules — it never imports a module,
|
||||
* only the lib. See knowledge_data-scopes-and-discovery (product intent).
|
||||
*/
|
||||
|
||||
import { discovery } from '@ng-eventually/client';
|
||||
|
||||
/**
|
||||
* The reference Festipod deposits into the global discovery index for a public
|
||||
* event. The SDK treats this as an opaque payload; only this domain module reads
|
||||
* its fields. `doc` is the event's own document NURI (where it physically lives,
|
||||
* so a discoverer can subscribe to it); `id`/`title` are discovery metadata so
|
||||
* the list can render before the document syncs.
|
||||
*/
|
||||
export interface EventIndexRef {
|
||||
kind: 'event';
|
||||
/** The event's document NURI (the discoverer subscribes to this to read it). */
|
||||
doc: string;
|
||||
/** The event's domain id (stable across the sync). */
|
||||
id: string;
|
||||
/** The event title — discovery-list metadata (renders before full sync). */
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a PUBLIC event discoverable: submit its reference to the global index
|
||||
* (the SDK act). `submitter` = the declaring user id when connected, or `null`
|
||||
* for an anonymous submission (mirrors the domain "identified if known, anonymous
|
||||
* otherwise"). Best-effort at the call site — a failed submission must not roll
|
||||
* back a successful event creation.
|
||||
*/
|
||||
export async function submitEventToIndex(
|
||||
ref: Omit<EventIndexRef, 'kind'>,
|
||||
submitter: string | null = null,
|
||||
): Promise<void> {
|
||||
const payload: EventIndexRef = { kind: 'event', ...ref };
|
||||
// Pass the event's document NURI so the SDK enforces PUBLIC-ONLY at the index:
|
||||
// a non-public document is refused (it must never leak past its scope).
|
||||
await discovery.submitToIndex(payload, { from: submitter, doc: ref.doc });
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the discovered public events from the global index (the SDK read). Maps
|
||||
* each event reference to its `{ doc, id, title }`; ignores non-event entries.
|
||||
* The caller subscribes to the returned `doc` NURIs to read the full events.
|
||||
*/
|
||||
export async function readDiscoveredEvents(): Promise<EventIndexRef[]> {
|
||||
const entries = await discovery.readIndex();
|
||||
const refs: EventIndexRef[] = [];
|
||||
for (const e of entries) {
|
||||
const p = e.ref as Partial<EventIndexRef> | null;
|
||||
if (!p || p.kind !== 'event' || !p.doc) continue;
|
||||
refs.push({ kind: 'event', doc: p.doc, id: p.id ?? '', title: p.title ?? '' });
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the global discovery index REACTIVELY (event-driven, no polling): the SDK
|
||||
* subscribes to the index document via `doc_subscribe`, so `onChange` fires on the
|
||||
* initial state push AND on every subsequent change to the index — a submission
|
||||
* made in ANOTHER session propagates here without a reload. The callback is a mere
|
||||
* change SIGNAL: the caller re-runs `readDiscoveredEvents()` on it (the read-model
|
||||
* pattern — subscribe as signal, re-query for the value). Returns an unsubscribe.
|
||||
*
|
||||
* A NEW public event created by a remote session appears reactively: its reference
|
||||
* lands in the index → the index doc gets a patch → `onChange` fires → the caller
|
||||
* relists → the new event doc enters the by-need read set and is itself subscribed.
|
||||
*/
|
||||
export function watchDiscoveredEvents(onChange: () => void): () => void {
|
||||
// `watchIndex` is already `doc_subscribe`-based in the lib (no setInterval); it
|
||||
// fires onEntries on the initial push and each later change to the index doc. We
|
||||
// ignore the entries payload and use it purely as a re-list SIGNAL.
|
||||
return discovery.watchIndex(() => onChange());
|
||||
}
|
||||
@@ -29,6 +29,7 @@
|
||||
*/
|
||||
|
||||
import { docs, escapeLiteral, assertNuri } from '@ng-eventually/client';
|
||||
import type { Nuri } from '@ng-eventually/client';
|
||||
import { sessionPromise } from '../utils/ngSession';
|
||||
|
||||
/** The RDF `@type` IRIs of the Festipod entities written per-document. */
|
||||
@@ -84,7 +85,7 @@ function renderTerm(t: EntityTerm): string | null {
|
||||
* the re-read consistent. `subject` is the entity `@id` (= its document NURI).
|
||||
*/
|
||||
export async function updateEntityField(
|
||||
graphNuri: string,
|
||||
graphNuri: Nuri,
|
||||
subject: string,
|
||||
field: string,
|
||||
term: EntityTerm,
|
||||
@@ -117,10 +118,10 @@ export async function updateEntityField(
|
||||
* surfaces it as the entity's `@id`).
|
||||
*/
|
||||
export async function writeEntity(
|
||||
graphNuri: string,
|
||||
graphNuri: Nuri,
|
||||
typeIri: string,
|
||||
fields: Record<string, EntityTerm>,
|
||||
): Promise<string> {
|
||||
): Promise<Nuri> {
|
||||
const sid = (await sessionPromise).session_id;
|
||||
// The entity IS its own document (one document per entity), so its subject IRI
|
||||
// is the DOCUMENT NURI itself (a `did:ng:…`). This gives the ORM a `did:ng:`
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
*/
|
||||
|
||||
import { inbox, docs, escapeLiteral, escapeIri, assertNuri } from '@ng-eventually/client';
|
||||
import type { Nuri } from '@ng-eventually/client';
|
||||
import { sessionPromise } from '../utils/ngSession';
|
||||
import { resolveInboxAnchor, listMyEntityDocs } from '../utils/storeRegistry';
|
||||
import { documentInbox, listMyEntityDocs } from '../utils/storeRegistry';
|
||||
import type { FpNotificationData } from './types';
|
||||
|
||||
/** Notification IRI/type constants (mirror the SHEX Notification shape). */
|
||||
@@ -116,15 +117,19 @@ export function canonicalEventId(id: string): string {
|
||||
* 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 SDK-resolved inbox anchor for the current session
|
||||
* (`resolveInboxAnchor()`). The app asks the SDK for the anchor by intent and
|
||||
* holds NO store id: the SDK owns where deposits land. Deposits are
|
||||
* discriminated by their `eventId` payload, and the inbox filters per event.
|
||||
* 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: string, explicitInbox?: string): Promise<string> {
|
||||
void eventId; // reserved: per-event inbox docs at migration
|
||||
export async function hostInboxNuri(eventId: Nuri, explicitInbox?: Nuri): Promise<Nuri> {
|
||||
if (explicitInbox) return explicitInbox;
|
||||
return resolveInboxAnchor();
|
||||
return documentInbox(eventId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,7 +164,7 @@ export function buildNotification(
|
||||
* domain identity belongs in the payload, not in the transport `from`.
|
||||
*/
|
||||
export async function depositRegistration(
|
||||
targetInbox: string,
|
||||
targetInbox: Nuri,
|
||||
eventId: string,
|
||||
registrantId: string | null,
|
||||
participationDoc?: string,
|
||||
@@ -190,7 +195,7 @@ export async function depositRegistration(
|
||||
* re-synced leave removes an already-removed registration → no double-decrement.
|
||||
*/
|
||||
export async function depositLeave(
|
||||
targetInbox: string,
|
||||
targetInbox: Nuri,
|
||||
eventId: string,
|
||||
registrantId: string | null,
|
||||
regUid?: string,
|
||||
@@ -247,7 +252,7 @@ export interface ActiveRegistration {
|
||||
* the count is 0 until someone joins, and the creator may join/leave like anyone.
|
||||
*/
|
||||
export async function materializeAttendance(
|
||||
targetInbox: string,
|
||||
targetInbox: Nuri,
|
||||
eventId: string,
|
||||
): Promise<ActiveRegistration[]> {
|
||||
// `inbox.readSynced`, not `inbox.read` — the two differ by CONTRACT, and this
|
||||
@@ -312,7 +317,7 @@ export async function materializeAttendance(
|
||||
* map each registration deposit to an `FpNotificationData` for `recipientId`.
|
||||
*/
|
||||
export async function readRegistrationNotifications(
|
||||
targetInbox: string,
|
||||
targetInbox: Nuri,
|
||||
recipientEventId: string,
|
||||
): Promise<FpNotificationData[]> {
|
||||
const deposits = await inbox.read(targetInbox);
|
||||
@@ -383,7 +388,7 @@ export interface DeleteParticipationResult {
|
||||
* safe (unlike a DELETE, it can never over-remove). */
|
||||
async function countParticipations(
|
||||
sid: string,
|
||||
graphNuri: string,
|
||||
graphNuri: Nuri,
|
||||
eventId: string,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
@@ -441,7 +446,7 @@ async function countParticipations(
|
||||
* state for the immediate UI, AND only once `remaining === 0`.
|
||||
*/
|
||||
export async function deleteParticipation(
|
||||
graphNuri: string,
|
||||
graphNuri: Nuri,
|
||||
eventId: string,
|
||||
userId: string,
|
||||
subjectIri?: string,
|
||||
@@ -522,7 +527,7 @@ export async function deleteParticipation(
|
||||
* used so notifications survive a refresh at the data level.
|
||||
*/
|
||||
export async function insertNotification(
|
||||
graphNuri: string,
|
||||
graphNuri: Nuri,
|
||||
notif: Omit<FpNotificationData, 'id'>,
|
||||
): Promise<string> {
|
||||
const sid = (await sessionPromise).session_id;
|
||||
|
||||
@@ -13,7 +13,8 @@ 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 } from '@ng-eventually/client';
|
||||
import { useShape, docs, inbox as docsInbox, isNuri } from '@ng-eventually/client';
|
||||
import type { Nuri } from '@ng-eventually/client';
|
||||
import { getCaps, getCurrentUser, setCurrentUser, resetCaps } 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';
|
||||
@@ -41,6 +42,23 @@ import { normalizeIdentifier } from '../context/AccountContext';
|
||||
// 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>
|
||||
@@ -106,11 +124,11 @@ function ConnectedHarness() {
|
||||
appDataRef.current = appData;
|
||||
|
||||
// Private store NURI — the inbox shim anchor + the ReadCap-governed document.
|
||||
const privateNuri = ngCtx.session && `did:ng:${ngCtx.session.private_store_id}`;
|
||||
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 = ngCtx.session && `did:ng:${ngCtx.session.protected_store_id}`;
|
||||
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>;
|
||||
@@ -306,14 +324,15 @@ 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). */
|
||||
/** 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(eventId);
|
||||
return regInboxNuri(asEventDoc(eventId));
|
||||
},
|
||||
/** Materialize the raw registration deposits for an event (curator). The
|
||||
* polyfill inbox is shared, so filter deposits to the given event. */
|
||||
* event's inbox may also carry other kinds, so filter to this event. */
|
||||
async readInboxDeposits(eventId: string) {
|
||||
const target = await regInboxNuri(eventId);
|
||||
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,
|
||||
@@ -322,12 +341,12 @@ function ConnectedHarness() {
|
||||
/** OPTION B — the owner's DERIVED active-registration set for an event
|
||||
* (`materializeAttendance`), matched on the CANONICAL event-id form. Used
|
||||
* by the @data convergence check to assert the DELTA (the just-joined user
|
||||
* is in the active set) rather than an absolute count — the shared inbox
|
||||
* anchor accumulates deposits across the wallet's life, so |active| is not
|
||||
* bounded to one scenario, but "contains this uid" IS deterministic. */
|
||||
* 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('');
|
||||
const target = await regmod.hostInboxNuri(asEventDoc(eventId));
|
||||
const active = await regmod.materializeAttendance(target, eventId);
|
||||
return active.map(r => r.userId);
|
||||
},
|
||||
@@ -348,29 +367,23 @@ 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 every protected per-entity
|
||||
// document (the union `listEntityDocs('protected')`) rather than the
|
||||
// store-root graph. This stays authoritative (bypasses the reactive set):
|
||||
// it counts the (event,user) triples actually persisted in the broker.
|
||||
// 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), NOT the all-accounts `listEntityDocs` fan-out. Each
|
||||
// @data scenario runs under a FRESH virtual account (freshScenarioIdentifier
|
||||
// in localStorage), whose participation docs live ONLY in that account's
|
||||
// protected scope index. The all-accounts fan-out (`allAccounts()`) does
|
||||
// not surface the fresh account here (its registry record isn't in the
|
||||
// enumerated set), so `listEntityDocs('protected')` returned 0 docs and the
|
||||
// authoritative count was a false 0 for a participation that provably
|
||||
// exists. `listMyEntityDocs(currentUser, 'protected')` reads exactly the
|
||||
// current account's own docs — the same bounded path the app writes/reads
|
||||
// and the sanctioned non-hanging enumeration. Falls back to the fan-out
|
||||
// only when no login is present (dev/demo).
|
||||
// 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 protectedDocs = currentUser
|
||||
? await reg.listMyEntityDocs(currentUser, 'protected')
|
||||
: await reg.listEntityDocs('protected');
|
||||
const holder = currentUser || getCurrentUser() || '';
|
||||
const protectedDocs = holder ? await reg.listMyEntityDocs(holder, 'protected') : [];
|
||||
let total = 0;
|
||||
for (const g of protectedDocs) {
|
||||
// Anchored default-graph (no `GRAPH` clause): participations are
|
||||
@@ -437,13 +450,20 @@ function ConnectedHarness() {
|
||||
* 0. Used by "le portefeuille est vide" — with one-document-per-entity and
|
||||
* 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. */
|
||||
* 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. */
|
||||
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.listEntityDocs('public'),
|
||||
reg.listEntityDocs('protected'),
|
||||
reg.listMyEntityDocs(holder, 'public'),
|
||||
reg.listMyEntityDocs(holder, 'protected'),
|
||||
]);
|
||||
const all = [...new Set([...pub, ...prot])];
|
||||
await Promise.all(all.map(g =>
|
||||
@@ -486,7 +506,7 @@ function ConnectedHarness() {
|
||||
*/
|
||||
async resetDataState() {
|
||||
const reg = await import('../utils/storeRegistry');
|
||||
const priv = `did:ng:${session.private_store_id}`;
|
||||
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
|
||||
@@ -517,7 +537,7 @@ function ConnectedHarness() {
|
||||
* 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 = `did:ng:${session.private_store_id}`;
|
||||
const priv: Nuri = `did:ng:${session.private_store_id}`;
|
||||
const t0 = Date.now();
|
||||
const del = `
|
||||
DELETE { GRAPH <${priv}> { ?s ?p ?o } }
|
||||
@@ -540,15 +560,19 @@ function ConnectedHarness() {
|
||||
documentNuri: protectedNuri,
|
||||
|
||||
/**
|
||||
* Put the domain document under a ReadCap policy: grant its read cap to
|
||||
* `reader` only, and set the current user to `user`. The lib's 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 filtered view.
|
||||
* 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();
|
||||
getCaps().grantRead(protectedNuri!, reader);
|
||||
setCurrentUser(reader);
|
||||
getCaps().open(protectedNuri, 'protected');
|
||||
setCurrentUser(user);
|
||||
setFilterActive(true);
|
||||
},
|
||||
@@ -561,36 +585,41 @@ function ConnectedHarness() {
|
||||
// --- PROTECTED + connections isolation (T03.b) ----------------------
|
||||
// Prove, through the SDK's ReadCap filter on the REAL ORM set, that a
|
||||
// PROTECTED document owned by `owner` is:
|
||||
// - hidden from an UNCONNECTED principal (only owner reads it);
|
||||
// - 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 (regardless of caps).
|
||||
// Uses `getCaps().open(doc, scope, owner)` exactly as the app wrapper
|
||||
// (storeRegistry.createEntityDoc) does; the protected participations
|
||||
// document is governed, and a separate makePublic'd doc models a public
|
||||
// entity. <FilterProbe> exposes the read-filtered VIEW over the protected
|
||||
// participations doc. `connect` calls the app's declareConnections — the
|
||||
// domain sharing act — which issues the SDK's directed read grants.
|
||||
// - a PUBLIC document stays readable throughout.
|
||||
// Each document's key is filed WHILE its owner is the connected identity —
|
||||
// `getCaps().open(doc, scope)` files under the current holder, exactly as the
|
||||
// SDK's own `createEntityDoc` does. 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 protected participations doc.
|
||||
// `connect` calls the app's declareConnections — the domain sharing act.
|
||||
governProtected(owner: string, reader: string) {
|
||||
if (!protectedNuri) throw new Error('no protected_store_id in session');
|
||||
resetCaps();
|
||||
resetConnections(); // clear the app's relationship registry too
|
||||
// The protected participations document (owner-only read at first).
|
||||
getCaps().open(protectedNuri!, 'protected', owner);
|
||||
// A public entity document — readable by anyone regardless of caps.
|
||||
getCaps().makePublic('did:ng:o:public-probe');
|
||||
setCurrentUser(owner);
|
||||
// The protected participations document (only its owner holds it at first).
|
||||
getCaps().open(protectedNuri, 'protected');
|
||||
// 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);
|
||||
setFilterActive(true);
|
||||
},
|
||||
/** Declare a bilateral owner↔reader connection (domain sharing act). Each
|
||||
* side asserts the other; only a two-sided link makes the app issue the
|
||||
* protected doc's directed read grant to the reader. */
|
||||
connect(a: string, b: string) {
|
||||
declareConnections([b], a); // a asserts b
|
||||
declareConnections([a], b); // b asserts a → bilateral link materializes
|
||||
* side asserts the other; only a two-sided link makes a session share its
|
||||
* own protected documents' keys into the neighbour's inbox. */
|
||||
async connect(a: string, b: string) {
|
||||
await declareConnections([b], a); // a asserts b
|
||||
await declareConnections([a], b); // b asserts a → bilateral link materializes
|
||||
},
|
||||
/** Does the CURRENT user read the public entity document — through the
|
||||
* SDK's own cap check — regardless of the protected caps? */
|
||||
/** 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().canRead('did:ng:o:public-probe', getCurrentUser());
|
||||
return getCaps().capFor(PUBLIC_PROBE) !== undefined;
|
||||
},
|
||||
|
||||
// --- Stopgap multi-store validation (see brief_2026-06-15_shared-wallet-shim) ---
|
||||
@@ -616,9 +645,11 @@ function ConnectedHarness() {
|
||||
reg.resetRegistryCache();
|
||||
const created = await reg.ensureAccount(identifier);
|
||||
reg.resetRegistryCache();
|
||||
const reloaded = (await reg.allAccounts()).find(
|
||||
a => a.id === identifier,
|
||||
) ?? null;
|
||||
// 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 };
|
||||
},
|
||||
|
||||
@@ -635,13 +666,20 @@ function ConnectedHarness() {
|
||||
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 listEntityDocs) can
|
||||
// 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.
|
||||
let listed: string[] = [];
|
||||
// (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();
|
||||
listed = await reg.listEntityDocs('public');
|
||||
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));
|
||||
}
|
||||
@@ -649,59 +687,14 @@ function ConnectedHarness() {
|
||||
return { docA, docB, listed };
|
||||
},
|
||||
|
||||
// --- Public discovery via the GLOBAL INDEX (T03.c) -----------------
|
||||
// Product-level scenario: a PUBLISHER account creates its own PUBLIC
|
||||
// event document and SUBMITS its reference to the SDK global discovery
|
||||
// index (submitEventToIndex). A separate, NON-connected DISCOVERER
|
||||
// account then READS THE INDEX (readDiscoveredEvents) — deposit →
|
||||
// materialize → read — and subscribes to the referenced document via a
|
||||
// real useShape({graphs}). No friendship/connection is ever declared
|
||||
// between them, and NO cross-account fan-out is used — discovery goes
|
||||
// through the index alone. Returns the publisher's doc + the index refs.
|
||||
// <FanoutProbe> is reused to mount the multi-graph subscription; the
|
||||
// event is written into the publisher doc before the reader lists it.
|
||||
async publishPublicEventAs(publisher: string, title: string) {
|
||||
const reg = await import('../utils/storeRegistry');
|
||||
const disc = await import('../data/discovery');
|
||||
reg.resetRegistryCache();
|
||||
console.error('[PROBE] publishPublicEventAs: ensureAccount(publisher)…');
|
||||
await reg.ensureAccount(publisher);
|
||||
console.error('[PROBE] publishPublicEventAs: createEntityDoc(publisher,public)…');
|
||||
const doc = await reg.createEntityDoc(publisher, 'public');
|
||||
console.error('[PROBE] publishPublicEventAs: publisher doc=' + doc);
|
||||
// Deposit AS the current identity: the inbox guard binds `from` to the
|
||||
// CURRENT user and rejects a spoofed `from`. So make the publisher the
|
||||
// current identity (its normalized-identifier key = the cap-owner key),
|
||||
// then submit WITHOUT a spoofed explicit `from` — the SDK stamps the
|
||||
// current identity itself (anonymous submission also allowed).
|
||||
setCurrentUser(normalizeIdentifier(publisher));
|
||||
console.error('[PROBE] publishPublicEventAs: submitEventToIndex…');
|
||||
await disc.submitEventToIndex({ doc, id: doc, title }, getCurrentUser());
|
||||
console.error('[PROBE] publishPublicEventAs: submitted OK');
|
||||
return { doc };
|
||||
},
|
||||
async discoverPublicEventsAs(discoverer: string) {
|
||||
const reg = await import('../utils/storeRegistry');
|
||||
const disc = await import('../data/discovery');
|
||||
// The discoverer account exists but is NOT connected to the publisher.
|
||||
// Become the discoverer identity (reads the world-readable public index).
|
||||
await reg.ensureAccount(discoverer);
|
||||
setCurrentUser(normalizeIdentifier(discoverer));
|
||||
reg.resetRegistryCache();
|
||||
// Read the GLOBAL INDEX (not a cross-account fan-out) to discover. The
|
||||
// submit deposit needs a moment to land in the broker's queryable graph
|
||||
// (same lag as any inbox deposit), so poll the index (bounded) until an
|
||||
// entry appears before mounting the multi-graph subscription.
|
||||
let listed: string[] = [];
|
||||
for (let i = 0; i < 20 && listed.length === 0; i++) {
|
||||
const refs = await disc.readDiscoveredEvents();
|
||||
listed = [...new Set(refs.map(r => r.doc).filter(Boolean))];
|
||||
if (listed.length) break;
|
||||
await new Promise(r => setTimeout(r, 250));
|
||||
}
|
||||
setFanoutGraphs(listed);
|
||||
return { listed };
|
||||
},
|
||||
// --- Public discovery: REMOVED ------------------------------------
|
||||
// The two probes that lived here (publishPublicEventAs /
|
||||
// discoverPublicEventsAs) exercised the SDK's global discovery index.
|
||||
// That index no longer exists: there is no discovery, a reader reaches a
|
||||
// document only by following a link it was given. The scenario they
|
||||
// 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
|
||||
|
||||
@@ -2,20 +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 exposes only a DIRECTED per-document
|
||||
* read grant (`getCaps().grantRead(doc, granteeId)`). So the app keeps its own
|
||||
* bilateral relationship registry here and, once a link is two-sided, issues the
|
||||
* directed read grants for the owner's protected documents — telling the SDK who
|
||||
* may read what. The app carries no access CHECK (that stays the SDK's job — see
|
||||
* knowledge_trust-model); it only declares the grants that follow from its own
|
||||
* relationship graph.
|
||||
* 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.
|
||||
*
|
||||
* 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 no grant is issued.
|
||||
* nothing: the owner never asserted them back, so nothing is shared with them.
|
||||
*/
|
||||
|
||||
import { getCaps } from '@ng-eventually/client/polyfill';
|
||||
import { capFor, shareCap } from '@ng-eventually/client/polyfill';
|
||||
import { listMyEntityDocs, walletInbox } from './storeRegistry';
|
||||
|
||||
/** Accumulates directed assertions and exposes the bilateral neighbourhood. */
|
||||
class RelationshipRegistry {
|
||||
@@ -58,25 +59,42 @@ const registry = new RelationshipRegistry();
|
||||
|
||||
/**
|
||||
* Declare the connections a session asserts, as `self`, to each id in `peers`,
|
||||
* then re-derive the directed read grants that follow. For every bilateral link
|
||||
* (both sides asserted), the app grants each neighbour the read cap of the other
|
||||
* side's protected documents (via `getCaps().protectedDocsOf(owner)` +
|
||||
* `grantRead`). Re-callable whenever the relationship graph changes — the
|
||||
* assertions and the grants only ever accumulate (additive, idempotent).
|
||||
* 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.
|
||||
*
|
||||
* `self` is the id of the asserting identity (its normalized-id key, the same key
|
||||
* the caps are opened with). A session only ever asserts its own side.
|
||||
* 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.
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
export function declareConnections(peers: Iterable<string>, self: string): void {
|
||||
export async function declareConnections(peers: Iterable<string>, self: string): Promise<void> {
|
||||
if (!self) return;
|
||||
for (const peer of peers) registry.assert(self, peer);
|
||||
|
||||
const caps = getCaps();
|
||||
// Issue directed grants for every bilateral link currently known. For a live
|
||||
// link owner↔neighbour, the neighbour may read the owner's protected docs.
|
||||
for (const owner of registry.asserters()) {
|
||||
for (const neighbour of registry.neighbors(owner)) {
|
||||
for (const doc of caps.protectedDocsOf(owner)) caps.grantRead(doc, neighbour);
|
||||
const neighbours = registry.neighbors(self);
|
||||
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');
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,24 +11,24 @@
|
||||
* them to the live subscription set (reactivity).
|
||||
*/
|
||||
|
||||
import type { Nuri } from '@ng-eventually/client';
|
||||
import { normalizeIdentifier } from '../context/AccountContext';
|
||||
import {
|
||||
seedEvents,
|
||||
seedUsers,
|
||||
} from '../data/seedData';
|
||||
import { writeEntity, ENTITY_TYPE, str, int, flt, bool } from '../data/entityWrites';
|
||||
import { submitEventToIndex } from '../data/discovery';
|
||||
|
||||
/** 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<string>;
|
||||
export type CreateEntityDoc = (owner: string, scope: Scope) => Promise<Nuri>;
|
||||
|
||||
export interface BootstrapResult {
|
||||
seeded: boolean;
|
||||
userIdMap: Map<string, string>;
|
||||
eventIdMap: Map<string, string>;
|
||||
/** Every per-entity document created, by scope — register these to subscribe. */
|
||||
createdDocs: { public: string[]; protected: string[] };
|
||||
createdDocs: { public: Nuri[]; protected: Nuri[] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +45,7 @@ export async function bootstrapWallet(
|
||||
createEntityDoc: CreateEntityDoc,
|
||||
owner?: string,
|
||||
): Promise<BootstrapResult> {
|
||||
const createdDocs = { public: [] as string[], protected: [] as string[] };
|
||||
const createdDocs: { public: Nuri[]; protected: Nuri[] } = { public: [], protected: [] };
|
||||
// Already has data → returning user, nothing to seed
|
||||
if (walletHasData) {
|
||||
console.log('[Bootstrap] Wallet already has data — skipping seed');
|
||||
@@ -114,12 +114,9 @@ export async function bootstrapWallet(
|
||||
coverImage: str(e.coverImage), hostName: str(e.hostName), hostInitials: str(e.hostInitials),
|
||||
});
|
||||
eventIdMap.set(e.id, id);
|
||||
// Make the seeded PUBLIC event discoverable, exactly like the product's
|
||||
// createEvent: submit its reference to the global discovery index. Awaited so
|
||||
// the index is populated before any read (a fresh virtual wallet has no "own"
|
||||
// seed docs — it sees the seeded events only through discovery).
|
||||
await submitEventToIndex({ doc: graph, id, title: e.title }, null)
|
||||
.catch(err => console.error('[Bootstrap] submit seed event to index failed:', err));
|
||||
// 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.
|
||||
}));
|
||||
console.log('[Bootstrap] Seeded', eventIdMap.size, 'events (participations created live)');
|
||||
|
||||
|
||||
@@ -98,7 +98,10 @@ export async function logoutNg(): Promise<void> {
|
||||
}
|
||||
|
||||
export interface NextGraphSession {
|
||||
ng: typeof NG;
|
||||
// `NG` IS the type of the SDK object (re-exported by @ng-eventually/client) —
|
||||
// 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;
|
||||
protected_store_id: string;
|
||||
private_store_id: string;
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
storeRegistry as libStoreRegistry,
|
||||
type AccountRecord as LibAccountRecord,
|
||||
} from '@ng-eventually/client';
|
||||
import { configureStoreRegistry, getCaps } from '@ng-eventually/client/polyfill';
|
||||
import { configureStoreRegistry } from '@ng-eventually/client/polyfill';
|
||||
import { sessionPromise } from './ngSession';
|
||||
import { normalizeIdentifier } from '../context/AccountContext';
|
||||
|
||||
@@ -66,35 +66,22 @@ configureStoreRegistry({
|
||||
export type AccountRecord = LibAccountRecord;
|
||||
|
||||
export const {
|
||||
loadShim,
|
||||
ensureAccount,
|
||||
resolveAccount,
|
||||
resolveWriteGraph,
|
||||
listEntityDocs,
|
||||
listMyEntityDocs,
|
||||
allAccounts,
|
||||
resolveReadGraphs,
|
||||
resetRegistryCache,
|
||||
// SDK-shaped scope resolvers — the app asks by scope, the lib resolves
|
||||
// placement (no store-id ever crosses the boundary).
|
||||
resolveScopeGraph,
|
||||
resolveInboxAnchor,
|
||||
// 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.
|
||||
createEntityDoc,
|
||||
} = libStoreRegistry;
|
||||
|
||||
/**
|
||||
* Create a per-entity document AND declare its ReadCap/WriteCap policy. The
|
||||
* lib's `createEntityDoc` stays domain-agnostic; the DOMAIN mapping (scope → who
|
||||
* may read) is Festipod's, so it lives here via `getCaps().open(doc, scope, owner)`:
|
||||
* - public → world-readable (`makePublic`) — events, meeting points
|
||||
* - protected → owner reads now; connections granted later (a separate grant)
|
||||
* - private → owner only
|
||||
* The owner always holds the WRITE cap (so only the owner may update the doc once
|
||||
* the guard is active). `owner` = the account identifier (the principal the app
|
||||
* sets via `setCurrentUser`).
|
||||
*/
|
||||
export async function createEntityDoc(identifier: string, scope: Scope): Promise<string> {
|
||||
const entityNuri = await libStoreRegistry.createEntityDoc(identifier, scope);
|
||||
// Declare the cap policy for the freshly-created entity document. `owner` is
|
||||
// the account identifier (principal). This is what makes ReadCap ACTIVE.
|
||||
getCaps().open(entityNuri, scope, normalizeIdentifier(identifier));
|
||||
return entityNuri;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user