Ng eventually #1

Open
Sylvain wants to merge 110 commits from ng-eventually into main
18 changed files with 1489 additions and 8602 deletions
Showing only changes of commit c1817607b4 - Show all commits
@@ -0,0 +1,7 @@
# Doc-debt — app-architecture
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED src/shared/context/FestipodDataContext.tsx @2026-08-03 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
+8
View File
@@ -0,0 +1,8 @@
# Doc-debt — bdd-testing
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED src/modules/event/steps/data/reconnexion.steps.ts @2026-08-03 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/test-harness/harness-ng.tsx @2026-08-03 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
+10
View File
@@ -0,0 +1,10 @@
# Doc-debt — data-layer
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED src/shared/data/entityWrites.ts @2026-08-03 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/utils/ngBootstrap.ts @2026-08-03 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/utils/ngSession.ts @2026-08-03 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/context/FestipodDataContext.tsx @2026-08-03 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
+7
View File
@@ -0,0 +1,7 @@
# Doc-debt — tech-stack
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED tsconfig.json @2026-08-03 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
File diff suppressed because one or more lines are too long
+1163 -8273
View File
File diff suppressed because it is too large Load Diff
@@ -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 {
+65 -28
View File
@@ -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)));
-91
View File
@@ -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());
}
+4 -3
View File
@@ -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:`
+20 -15
View File
@@ -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;
+113 -120
View File
@@ -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
+40 -22
View File
@@ -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);
}
}
}
+7 -10
View File
@@ -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)');
+4 -1
View File
@@ -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;
+12 -25
View File
@@ -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;
}
+4 -1
View File
@@ -21,7 +21,10 @@
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"baseUrl": ".",
// No `baseUrl`: deprecated in TypeScript 6, where it is reported as an ERROR
// that aborts the whole compilation — `tsc --noEmit` then exits 0 having
// checked nothing, so the typecheck gate silently passes everything. `paths`
// resolves relative to this file since TS 4.4, so dropping it changes nothing.
"paths": {
"@/*": ["./src/*"]
},