feat(auth)+refactor(app): identifier at the access barrier; adopt the lib fidelity refactor
Consumer-side of the @ng-eventually/client fidelity pass, plus the identifier UX: - Identity: the user types an IDENTIFIER at the access barrier (AccessGateScreen), in the same act that opens the shared wallet — the separate 'pick a username' screen (ConnexionScreen) is removed. The identifier is a technical id (a pseudo in practice, not a Festipod username), normalized (trim, @-stripped, lowercased) and persisted before the broker redirect, then handed to the SDK as the identity. AccountContext keeps its API but its stored value is now this normalized id. - Relationship/connections are app-owned: new src/shared/utils/connections.ts holds the bilateral registry and maps each link to the SDK's directed grantRead(doc, grantee); the lib no longer carries a connection concept. Rewired FestipodData and the @data harness to it. - Login removed: accounts use the SDK's IdentityStore (set/clear/get); no faux login/logout framing in the SDK boundary. Doctrine reconciled: app-security (knowledge_authentication flow, knowledge_trust-model directed grants, decision_2026-07-06_identifier-at-access-barrier), data-layer (knowledge_context-internals: stable id principal + single-seed), app-architecture (knowledge_screens auth inventory), bdd-testing (caveat_wallet-bloat-hang). App gates: tsc no new errors, build OK. @data path unaffected (harness bypasses the gate and sets identity directly; login() is not on that path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,40 +1,49 @@
|
||||
/**
|
||||
* AccountContext — the application-level login.
|
||||
* AccountContext — the current identity of the stopgap.
|
||||
*
|
||||
* STOPGAP (see decision_2026-06-15_shared-wallet-login-flow.md).
|
||||
*
|
||||
* The real NextGraph login (a redirect to the broker, opening the single
|
||||
* SHARED wallet) is perceived by the user as a *technical access barrier*,
|
||||
* NOT as a login. THIS context is what the user perceives as the login:
|
||||
* they pick a username (no password — declarative), which is persisted in
|
||||
* localStorage so the "session" survives reloads and a different device,
|
||||
* re-opening the same shared wallet, lands on the same accounts.
|
||||
* The user names their virtual space with an IDENTIFIER at the access barrier
|
||||
* (AccessGateScreen), in the same act that opens the SHARED wallet — there is no
|
||||
* separate app login. The identifier is a technical id (a pseudo in practice,
|
||||
* not a Festipod username): it is normalized (trimmed, `@`-stripped, lowercased)
|
||||
* and persisted in localStorage, so a reload — or another device re-opening the
|
||||
* same shared wallet — lands on the same space.
|
||||
*
|
||||
* `login()` / `logout()` here are FAUX: they only read/write the username in
|
||||
* localStorage. They must NEVER call NextGraph (ng.session_stop /
|
||||
* wallet_close) — the shared wallet stays open underneath. The real logout
|
||||
* lives, hidden, in Settings.
|
||||
* `login()` / `logout()` here only read/write that identifier in localStorage;
|
||||
* they NEVER call NextGraph (ng.session_stop / wallet_close) — the shared wallet
|
||||
* stays open underneath. The real logout lives, hidden, in Settings.
|
||||
*
|
||||
* The stored value IS the identity id handed to the SDK
|
||||
* (`setCurrentUser(identifier)`); it is the key the caps and the shim account
|
||||
* are keyed on. The `username` field name is kept for its many consumers, but it
|
||||
* now holds this normalized identifier, not a mixed-case display handle.
|
||||
*
|
||||
* Default value is non-null so `useAccount()` never throws outside a provider
|
||||
* (the @ui render harness wraps screens without this provider).
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useState, useCallback, useMemo, useEffect, type ReactNode } from 'react';
|
||||
// Thin React wrapper over the lib's framework-agnostic accounts core (T01.c):
|
||||
// AccountStore (localStorage-backed faux login) + normalizeUsername. This file
|
||||
// keeps ONLY the React Context/Provider glue; the login/logout/normalize logic
|
||||
// lives in the lib. See decision_2026-06-17_eventually-library.
|
||||
// The SDK's framework-agnostic IdentityStore persists the current identity id
|
||||
// (localStorage-backed). This file keeps the React Context/Provider glue and the
|
||||
// Festipod username handle; `normalizeUsername` (the handle → id mapping) is the
|
||||
// app's own choice. See decision_2026-06-17_eventually-library.
|
||||
import { accounts } from '@ng-eventually/client';
|
||||
// Declare the current identity to the SDK: the app tells NextGraph WHO is
|
||||
// reading, so the SDK returns only the data this identity is authorized to see
|
||||
// (isolation is the SDK's job — see knowledge_trust-model). This is the SDK's
|
||||
// "current identity" call, not an access rule the app enforces itself.
|
||||
// Set the current identity on the SDK: the app tells NextGraph WHO is reading, so
|
||||
// the SDK returns only the data this identity is authorized to see (isolation is
|
||||
// the SDK's job — see knowledge_trust-model). This is the SDK's "current
|
||||
// identity" call, not an access rule the app enforces itself.
|
||||
import { setCurrentUser } from '@ng-eventually/client/polyfill';
|
||||
|
||||
// Preserve the historical Festipod localStorage key so existing "logins" survive
|
||||
// (the lib's default key differs; we pin ours explicitly → no behavior change).
|
||||
// (the SDK's default key differs; we pin ours explicitly → no behavior change).
|
||||
const STORAGE_KEY = 'festipod.account.username';
|
||||
|
||||
/** Normalise a username handle into the identity id the SDK is given. */
|
||||
export function normalizeUsername(username: string | null | undefined): string {
|
||||
return (username ?? '').trim().replace(/^@+/, '').toLowerCase();
|
||||
}
|
||||
|
||||
export interface AccountContextValue {
|
||||
/** App-level identity (the perceived "login"). null = not connected. */
|
||||
username: string | null;
|
||||
@@ -44,10 +53,10 @@ export interface AccountContextValue {
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
/** Browser-safe storage (null in SSR → lib store degrades to non-persisting). */
|
||||
function makeStore(): accounts.AccountStore {
|
||||
/** Browser-safe storage (null in SSR → the store degrades to non-persisting). */
|
||||
function makeStore(): accounts.IdentityStore {
|
||||
const ls = typeof window !== 'undefined' ? window.localStorage : null;
|
||||
return new accounts.AccountStore(ls, STORAGE_KEY);
|
||||
return new accounts.IdentityStore(ls, STORAGE_KEY);
|
||||
}
|
||||
|
||||
const AccountContext = createContext<AccountContextValue>({
|
||||
@@ -62,19 +71,22 @@ export function AccountProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// Tell the SDK who the current identity is, on mount and whenever the account
|
||||
// changes (login/logout). The SDK uses it to gate reads to what this identity
|
||||
// may see; the app performs no access check of its own. Normalize so the id
|
||||
// matches the same principal key everything else uses.
|
||||
// may see; the app performs no access check of its own. Normalize the username
|
||||
// handle into the identity id everything else uses.
|
||||
useEffect(() => {
|
||||
setCurrentUser(username ? accounts.normalizeUsername(username) : null);
|
||||
setCurrentUser(username ? normalizeUsername(username) : null);
|
||||
}, [username]);
|
||||
|
||||
const login = useCallback((name: string) => {
|
||||
const next = store.login(name);
|
||||
// The identifier is normalized (trimmed, `@`-stripped, lowercased) at the
|
||||
// door, so the stored value IS the identity id — the same key the SDK, the
|
||||
// caps and the shim account are keyed on. No mixed-case handle to reconcile.
|
||||
const next = store.set(normalizeUsername(name));
|
||||
if (next) setUsername(next);
|
||||
}, [store]);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
store.logout();
|
||||
store.clear();
|
||||
setUsername(null);
|
||||
}, [store]);
|
||||
|
||||
@@ -88,9 +100,3 @@ export function AccountProvider({ children }: { children: ReactNode }) {
|
||||
export function useAccount(): AccountContextValue {
|
||||
return useContext(AccountContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a username for matching (case-insensitive, optional leading `@`).
|
||||
* Re-exported from the lib's accounts core so app callers keep this import path.
|
||||
*/
|
||||
export const normalizeUsername = accounts.normalizeUsername;
|
||||
|
||||
@@ -26,7 +26,9 @@ import {
|
||||
} from '../data/seedData';
|
||||
import { useNextGraph } from './NextGraphContext';
|
||||
import { useAccount, normalizeUsername } from './AccountContext';
|
||||
import { declareConnections } from '@ng-eventually/client/polyfill';
|
||||
// Relationship is a Festipod concept: the app keeps its own bilateral registry
|
||||
// and hands the SDK only directed read grants (see shared/utils/connections).
|
||||
import { declareConnections } from '../utils/connections';
|
||||
import { listMyEntityDocs, createEntityDoc } from '../utils/storeRegistry';
|
||||
import { submitEventToIndex, readDiscoveredEvents } from '../data/discovery';
|
||||
import { readEntities } from '../data/readEntities';
|
||||
@@ -444,24 +446,23 @@ function useNgData(): FestipodDataContextValue {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, hostedEventIds.join('|')]);
|
||||
|
||||
// Protected-sharing act: declare the CURRENT identity's own connections to the
|
||||
// SDK so an owner's connections may read that owner's PROTECTED entities (public
|
||||
// = all; private = owner only). The declaration is AUTHENTICATED — it names only
|
||||
// the current user's own peers and is bound to the current identity by the SDK;
|
||||
// a protected read is granted only where BOTH sides connected (bilateral). The
|
||||
// app carries NO access logic (see knowledge_trust-model) — it only declares its
|
||||
// domain fact (friendships) and trusts the SDK's enforcement. No store id, no
|
||||
// document NURI crosses here.
|
||||
// Protected-sharing act: the app owns the relationship concept — it declares the
|
||||
// current identity's own connections (a Festipod domain fact) and, for each
|
||||
// bilateral link, hands the SDK directed read grants so an owner's connections
|
||||
// may read that owner's PROTECTED entities (public = all; private = owner only).
|
||||
// The declaration names only the current user's own peers, asserted as the
|
||||
// current identity. The app carries no access CHECK (see knowledge_trust-model)
|
||||
// — it only declares its own relationship graph, then trusts the SDK to enforce
|
||||
// the resulting per-document grants. No store id, no document NURI crosses here.
|
||||
useEffect(() => {
|
||||
if (!ready || !currentUserId) return;
|
||||
// Connection principals must be the SAME key space as the cap owners: the
|
||||
// SDK keys caps on the NORMALIZED USERNAME (`createEntityDoc` opens each doc
|
||||
// with `normalizeUsername(owner)`, and login sets the reader identity via
|
||||
// `setCurrentUser(normalizeUsername(username))`). The app models friendships
|
||||
// with user IRIs, so map each peer IRI → its username key before declaring,
|
||||
// and assert AS the current user's username key. Peers with no known username
|
||||
// are skipped (can't be keyed). This is what makes "protected = my bilateral
|
||||
// connections" actually discriminate in @data.
|
||||
// Connection ids must be the SAME key space as the cap owners: each doc is
|
||||
// opened with `normalizeUsername(owner)`, and the reader identity is set via
|
||||
// `setCurrentUser(normalizeUsername(username))`. The app models friendships
|
||||
// with user IRIs, so map each peer IRI → its id key before declaring, and
|
||||
// assert AS the current user's id key. Peers with no known id are skipped
|
||||
// (can't be keyed). This is what makes "protected = my bilateral connections"
|
||||
// actually discriminate in @data.
|
||||
const usernameOf = (userIri: string): string | undefined => {
|
||||
const u = users.find(x => x.id === userIri);
|
||||
return u?.username ? normalizeUsername(u.username) : undefined;
|
||||
|
||||
@@ -583,7 +583,7 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
|
||||
// UNIQUE app-level username into localStorage['festipod.account.username'] on
|
||||
// EVERY origin (the init script runs in each frame before its scripts do —
|
||||
// including the harness iframe on 127.0.0.1). At mount the harness's
|
||||
// AccountStore.get() then reads THIS fresh username, so `if (!username)
|
||||
// IdentityStore.get() then reads THIS fresh username, so `if (!username)
|
||||
// login(DEFAULT_HARNESS_USER)` is skipped and the scenario runs on a fresh,
|
||||
// empty virtual wallet. Overwrites any value persisted in the Chromium profile
|
||||
// (init scripts run on each navigation), so no accumulated wallet leaks in.
|
||||
|
||||
@@ -14,7 +14,9 @@ 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 { getCaps, getCurrentUser, setCurrentUser, resetCaps, declareConnections } from '@ng-eventually/client/polyfill';
|
||||
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';
|
||||
import { hostInboxNuri as regInboxNuri } from '../data/registration';
|
||||
import type { DeepSignalSet } from '@ng-eventually/client';
|
||||
// doc_create goes through the lib's `docs` primitive (T01.a): it calls the REAL
|
||||
@@ -476,10 +478,11 @@ function ConnectedHarness() {
|
||||
// (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 SDK's declareConnections — the
|
||||
// app's domain sharing act — never touches a doc NURI or the registry.
|
||||
// participations doc. `connect` calls the app's declareConnections — the
|
||||
// domain sharing act — which issues the SDK's directed read grants.
|
||||
governProtected(owner: string, reader: string) {
|
||||
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.
|
||||
@@ -487,9 +490,9 @@ function ConnectedHarness() {
|
||||
setCurrentUser(reader);
|
||||
setFilterActive(true);
|
||||
},
|
||||
/** Declare a BILATERAL owner↔reader connection to the SDK (domain sharing
|
||||
* act). Each side asserts the other (bound to that identity); only then
|
||||
* does the SDK issue the protected doc's read cap to the connection. */
|
||||
/** 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
|
||||
@@ -524,7 +527,7 @@ function ConnectedHarness() {
|
||||
const created = await reg.ensureAccount(username);
|
||||
reg.resetRegistryCache();
|
||||
const reloaded = (await reg.allAccounts()).find(
|
||||
a => a.username === username,
|
||||
a => a.id === username,
|
||||
) ?? null;
|
||||
return { created, reloaded };
|
||||
},
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { getCaps } from '@ng-eventually/client/polyfill';
|
||||
|
||||
/** Accumulates directed assertions and exposes the bilateral neighbourhood. */
|
||||
class RelationshipRegistry {
|
||||
/** identity id → the set of ids it has asserted a link TO. */
|
||||
private asserted = new Map<string, Set<string>>();
|
||||
|
||||
/** Record that `from` asserts a link to `to` (one direction only). */
|
||||
assert(from: string, to: string): void {
|
||||
if (!from || !to || from === to) return;
|
||||
let s = this.asserted.get(from);
|
||||
if (!s) this.asserted.set(from, (s = new Set()));
|
||||
s.add(to);
|
||||
}
|
||||
|
||||
/** Has `from` asserted a link to `to` (one direction)? */
|
||||
private hasAsserted(from: string, to: string): boolean {
|
||||
return this.asserted.get(from)?.has(to) ?? false;
|
||||
}
|
||||
|
||||
/** The bilateral neighbours of `id`: every `q` that `id` and `q` each asserted. */
|
||||
neighbors(id: string): Set<string> {
|
||||
const out = new Set<string>();
|
||||
for (const to of this.asserted.get(id) ?? []) {
|
||||
if (this.hasAsserted(to, id)) out.add(to);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every id that has asserted at least one link. */
|
||||
asserters(): Iterable<string> {
|
||||
return this.asserted.keys();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.asserted.clear();
|
||||
}
|
||||
}
|
||||
|
||||
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).
|
||||
*
|
||||
* `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.
|
||||
*/
|
||||
export function declareConnections(peers: Iterable<string>, self: string): 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset the relationship registry (mainly for tests / fresh sessions). */
|
||||
export function resetConnections(): void {
|
||||
registry.clear();
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export function entityScope(kind: EntityKind): Scope {
|
||||
}
|
||||
|
||||
// --- Consumer wiring injected into the lib's storeRegistry ---
|
||||
// The lib is Festipod-agnostic: it reaches the session and the username
|
||||
// The lib is Festipod-agnostic: it reaches the session and the identity-id
|
||||
// normalization through these injected deps. Idempotent module-load side effect
|
||||
// (the app imports storeRegistry before any registry call).
|
||||
configureStoreRegistry({
|
||||
@@ -51,7 +51,8 @@ configureStoreRegistry({
|
||||
publicStoreId: session.public_store_id,
|
||||
};
|
||||
},
|
||||
normalizeUser: normalizeUsername,
|
||||
// The app maps its username handle to the identity id the lib keys on.
|
||||
normalizeId: normalizeUsername,
|
||||
});
|
||||
|
||||
// --- Re-export the lib's account record + registry surface (unchanged API) ---
|
||||
|
||||
Reference in New Issue
Block a user