feat(client): generic shared-wallet shim surface (docs/storeRegistry/isolation/accounts)

Port the shared-wallet shim mechanics from the Festipod app into the
generic @ng-eventually/client library — zero app-specific knowledge, the
consumer injects the domain (entity->scope mapping, connections, storage).

New namespaces exposed from src/index.ts:
- docs      docCreate/sparqlUpdate/sparqlQuery via the REAL injected `ng`
            (getConfig().ng), never the public makeNg proxy — the JS-over-
            iframe double proxy breaks doc_create postMessage marshaling
            (DataCloneError). Validated hard constraint.
- storeRegistry  generic (account,scope)->NURI resolver, createEntityDoc/
            listEntityDocs + per-scope index, sharedWalletShim in the
            private_store, cache. Consumer wiring injected via
            configureStoreRegistry({ getSession, normalizeUser }).
- isolation  pure applyIsolation (public=all / protected=owner+connections
            / private=owner); accessors + connection graph injected.
- accounts  AccountStore (localStorage-backed faux login, storage injected)
            + normalizeUsername. React wrapper intentionally NOT ported.

polyfill.ts gains configureStoreRegistry/getStoreRegistryDeps + resetConfig.
36/36 bun test, tsc --noEmit rc=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-03 10:12:55 +02:00
parent 88d96857fb
commit 654cb90d99
10 changed files with 1097 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
/**
* accounts — the framework-agnostic core of the *simulated* app-level login.
*
* STOPGAP — part of the shared-wallet shim. The real NextGraph login (redirect
* to the broker, opening the single SHARED wallet) is perceived as a technical
* access barrier, not as a login. THIS layer is the perceived login: the user
* picks a username (no password — declarative), persisted in `localStorage` so
* the "session" survives reloads and lands on the same account when the shared
* wallet re-opens.
*
* `login()` / `logout()` here are FAUX: they only read/write the username in
* storage. They must NEVER call NextGraph (no session_stop / wallet_close) — the
* shared wallet stays open underneath. The real logout lives elsewhere.
*
* FRAMEWORK-AGNOSTIC on purpose: NO React, no DOM assumption beyond an optional
* storage. The React `Context`/`Provider` is a thin wrapper that stays in the
* consumer app (it wires `useState` around {@link login}/{@link logout}). The
* lib must not force a React dependency. Removed at migration.
*/
/** localStorage key holding the perceived-login username. */
export const ACCOUNT_STORAGE_KEY = "ng-eventually.account.username";
/**
* Minimal storage contract (a subset of the Web `Storage` interface). The
* consumer injects one — `window.localStorage` in a browser, a fake in tests —
* so this stays framework/DOM-agnostic. When none is available (SSR, no
* `window`), pass `null` and the store degrades to in-memory-null (no persist).
*/
export interface AccountStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
/**
* Normalize a username for matching: case-insensitive, optional leading `@`
* stripped, trimmed. Lets "marie", "@marie", "Marie" match interchangeably.
* Pure — safe to use as the shim key normalizer too.
*/
export function normalizeUsername(username: string | null | undefined): string {
return (username ?? "").trim().replace(/^@+/, "").toLowerCase();
}
/**
* The persisted app-level account. A tiny store around an injected
* {@link AccountStorage}. Holds no React state; the consumer's Provider mirrors
* `get()` into framework state and re-reads after `login`/`logout`.
*/
export class AccountStore {
private readonly storage: AccountStorage | null;
private readonly key: string;
constructor(storage: AccountStorage | null, key: string = ACCOUNT_STORAGE_KEY) {
this.storage = storage;
this.key = key;
}
/** The current app-level username (the perceived "login"). null = not connected. */
get(): string | null {
if (!this.storage) return null;
try {
return this.storage.getItem(this.key);
} catch {
return null;
}
}
/**
* Faux login — persist the (trimmed) username. Empty/blank is ignored and the
* previous value is kept (returns the resulting username, or null). No NG call.
*/
login(username: string): string | null {
const clean = username.trim();
if (!clean) return this.get();
if (this.storage) {
try {
this.storage.setItem(this.key, clean);
} catch {
/* ignore — staging, no security */
}
}
return clean;
}
/** Faux logout — clear the username only. No NG call. */
logout(): void {
if (this.storage) {
try {
this.storage.removeItem(this.key);
} catch {
/* ignore */
}
}
}
}
/**
* Convenience factory using `globalThis.localStorage` when present, else a
* null (non-persisting) store — so the same call is safe in browser and SSR.
*/
export function browserAccountStore(key: string = ACCOUNT_STORAGE_KEY): AccountStore {
const ls =
typeof globalThis !== "undefined" &&
(globalThis as { localStorage?: AccountStorage }).localStorage
? (globalThis as { localStorage: AccountStorage }).localStorage
: null;
return new AccountStore(ls, key);
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Low-level document + SPARQL primitives.
*
* These call the **REAL injected `ng`** (`getConfig().ng`) DIRECTLY — never the
* public `ng` proxy (`makeNg`). This is a validated hard constraint, NOT a style
* choice: the public `ng` is a JS `Proxy` over `@ng-org/web`'s iframe-RPC proxy,
* and layering our Proxy on top breaks `doc_create`'s `postMessage` marshaling
* (`DataCloneError: function ... could not be cloned`). Reaching the real `ng`
* held in the config avoids the double-proxy. Do NOT import from `./ng-proxy`.
*
* Signatures mirror the real `@ng-org/web` `ng` surface (verified against the
* app's storeRegistry usage), so this is a drop-in for those raw calls.
*/
import { getConfig } from "./polyfill";
import type { Nuri } from "./types";
/**
* Create one document → its NURI.
*
* Mirrors `ng.doc_create(session_id, crdt, cls, dest, store_repo?)`. For a graph
* document in the (shared) private store: `docCreate(sid, "Graph", "data:graph",
* "store")` (store_repo left undefined → private store).
*/
export async function docCreate(
sessionId: string,
crdt: string,
cls: string,
dest: string,
store?: unknown,
): Promise<Nuri> {
const { ng } = getConfig();
return ng.doc_create(sessionId, crdt, cls, dest, store);
}
/**
* Run a SPARQL UPDATE (INSERT/DELETE DATA, etc.).
*
* Mirrors `ng.sparql_update(session_id, query, anchor?)`, where `anchor` is the
* document NURI the update is scoped/base'd to (optional).
*/
export async function sparqlUpdate(
sessionId: string,
query: string,
anchor?: Nuri,
): Promise<void> {
const { ng } = getConfig();
return ng.sparql_update(sessionId, query, anchor);
}
/**
* Run a SPARQL SELECT/CONSTRUCT/ASK query → the raw SDK result.
*
* Mirrors `ng.sparql_query(session_id, query, base?, anchor?)`. `base` is the
* query base IRI (usually `undefined`); `anchor` is the document NURI to query.
*/
export async function sparqlQuery(
sessionId: string,
query: string,
base?: string,
anchor?: Nuri,
): Promise<unknown> {
const { ng } = getConfig();
return ng.sparql_query(sessionId, query, base, anchor);
}
+7
View File
@@ -15,6 +15,13 @@ export * from "./types";
export { useShape } from "./use-shape";
export { init, initNg } from "./lifecycle";
export * as inbox from "./inbox";
export * as docs from "./docs";
export * as storeRegistry from "./store-registry";
export type { AccountRecord, RegistrySession } from "./store-registry";
export * as isolation from "./isolation";
export type { Connections, IsolationAccessors } from "./isolation";
export * as accounts from "./accounts";
export type { AccountStorage } from "./accounts";
// SDK type re-exports — so the app imports these from @ng-eventually/client too,
// not from @ng-org. `export type` is ERASED at build, so this adds NO runtime
+138
View File
@@ -0,0 +1,138 @@
/**
* isolation — APPLICATION-LAYER visibility filter, per **account + connections**.
*
* STOPGAP (shared-wallet shim): with one shared wallet everything is physically
* readable. To make staging *behave* like the target infra, the consumer HONORS
* a visibility matrix by filtering reads relative to the current principal and
* its social connections:
*
* - public → visible to everyone
* - protected → visible to the owner + its direct connections
* - private → visible to the owner only
*
* Pure functions — no NextGraph, no React, ZERO application domain. The consumer
* supplies (a) the connection graph (who is connected to whom — the lib does NOT
* invent it) and (b) accessors telling, for each item, its owner and scope.
*
* ── isolation vs ReadCap (read-filter.ts / caps.ts) — they COEXIST ──────────
* Two DIFFERENT axes, deliberately kept separate:
*
* ReadCap ({@link ./caps} + {@link ./read-filter})
* - Unit : the DOCUMENT (an item's `@graph` = the repo it lives in).
* - Question: does the current principal HOLD this document's read cap?
* - Models : NextGraph's native capability delivery (broker-enforced).
* - Grants : explicit, per-document (grantRead / makePublic).
*
* isolation (this file)
* - Unit : the ITEM / record (per owner + scope).
* - Question: given WHO is connected to WHOM, may this principal see it?
* - Models : an application social-visibility policy, above the doc layer.
* - Grants : implicit, derived from the connection graph + the item's scope.
*
* They are NOT redundant and do NOT merge: ReadCap answers "can the wallet even
* fetch this document"; isolation answers "should the app show this record to
* this account given its relationships". The connection-derived `protected`
* visibility of isolation has no equivalent in the per-document cap model. Both
* are removable scaffolds; each disappears against a different piece of the real
* infra (caps → native ReadCaps; isolation → real per-account social graph +
* per-account wallets). See T01.c ## Result for the recorded decision.
*/
import type { PrincipalId, Scope } from "./types";
/**
* The connection graph, consumer-injected. An undirected "is connected to"
* relation between principals (e.g. accepted friendships). The lib never builds
* this — the consumer maps its own domain (friendships, follows, …) onto it.
*/
export interface Connections {
/** All principals directly connected to `principal` (excluding itself). */
neighbors(principal: PrincipalId): Iterable<PrincipalId>;
}
/**
* Build a {@link Connections} from a flat list of undirected links. A link
* `{ a, b }` connects `a` and `b` both ways. Convenience for the common case;
* the consumer may implement {@link Connections} directly instead.
*/
export function connectionsFromLinks(
links: Iterable<{ a: PrincipalId; b: PrincipalId }>,
): Connections {
const adj = new Map<PrincipalId, Set<PrincipalId>>();
const link = (x: PrincipalId, y: PrincipalId) => {
let s = adj.get(x);
if (!s) adj.set(x, (s = new Set()));
s.add(y);
};
for (const { a, b } of links) {
link(a, b);
link(b, a);
}
return {
neighbors: (principal) => adj.get(principal) ?? new Set<PrincipalId>(),
};
}
/**
* The set the current principal may see PROTECTED data for: itself + its direct
* connections. Pure over the injected {@link Connections}.
*/
export function visibleSet(current: PrincipalId, connections: Connections): Set<PrincipalId> {
const set = new Set<PrincipalId>([current]);
for (const n of connections.neighbors(current)) set.add(n);
return set;
}
/**
* May `current` see an item owned by `owner` at visibility `scope`, given the
* `visible` set (self + connections, precomputed via {@link visibleSet})?
*
* - public → always
* - protected → owner ∈ visible
* - private → owner === current
*/
export function isVisible(
scope: Scope,
owner: PrincipalId,
current: PrincipalId,
visible: Set<PrincipalId>,
): boolean {
switch (scope) {
case "public":
return true;
case "protected":
return visible.has(owner);
case "private":
return owner === current;
}
}
/** How to read an item's owner + scope. Consumer-supplied — keeps this generic. */
export interface IsolationAccessors<T> {
/** The principal that owns / authored the item. */
ownerOf: (item: T) => PrincipalId;
/** The item's visibility scope. */
scopeOf: (item: T) => Scope;
}
/**
* Pure: keep only the items `current` is allowed to see, per the visibility
* matrix and the injected connection graph.
*
* Empty `current` (no identity yet, e.g. during hydration) → pass everything
* through, so the app doesn't blank out mid-load (matches the app's prior
* `if (!currentUserId) return data` guard).
*/
export function applyIsolation<T>(
items: Iterable<T>,
current: PrincipalId,
connections: Connections,
accessors: IsolationAccessors<T>,
): T[] {
const all = [...items];
if (!current) return all;
const visible = visibleSet(current, connections);
return all.filter((item) =>
isVisible(accessors.scopeOf(item), accessors.ownerOf(item), current, visible),
);
}
+48
View File
@@ -9,8 +9,22 @@
*/
import type { NgLike, UseShapeLike, PrincipalId } from "./types";
import type { RegistrySession } from "./store-registry";
import { CapRegistry } from "./caps";
/**
* Consumer-injected dependencies of the storeRegistry (polyfill-era). The
* registry itself is generic (it knows only native scopes); the consumer wires
* up how to reach the shared-wallet session and how to normalize a username
* used as the shim key. Removed at migration along with the whole shim.
*/
export interface StoreRegistryDeps {
/** Resolve the current shared-wallet session (id + private-store anchor). */
getSession: () => Promise<RegistrySession>;
/** Normalize a username for shim keying. Default: trim (identity-ish). */
normalizeUser?: (username: string) => string;
}
export interface EventuallyConfig {
/** The REAL `@ng-org/web` `ng` (injected to avoid a hard import / alias loop). */
ng: NgLike;
@@ -28,6 +42,7 @@ export interface EventuallyConfig {
let cfg: EventuallyConfig | null = null;
let currentUser: PrincipalId | null = null;
let registryDeps: Required<StoreRegistryDeps> | null = null;
/** The emulated ReadCap/WriteCap registry. Empty until the app declares caps;
* while it has no read policy the read filter passes through (no regression). */
let caps = new CapRegistry();
@@ -43,6 +58,39 @@ export function getConfig(): EventuallyConfig {
return cfg;
}
/** Reset the injected config back to un-configured (mainly for tests, so a
* suite that calls configure() can restore the not-configured guard state). */
export function resetConfig(): void {
cfg = null;
currentUser = null;
}
/**
* Wire the storeRegistry's consumer-injected dependencies (session + username
* normalization). Must be called before any storeRegistry.* use. Separate from
* {@link configure} because it's storeRegistry-specific and, like the shim,
* disappears at migration.
*/
export function configureStoreRegistry(deps: StoreRegistryDeps): void {
registryDeps = {
getSession: deps.getSession,
normalizeUser: deps.normalizeUser ?? ((u: string) => u.trim()),
};
}
/** @internal — used by the storeRegistry to reach its injected dependencies. */
export function getStoreRegistryDeps(): Required<StoreRegistryDeps> {
if (!registryDeps) {
throw new Error("[ng-eventually] configureStoreRegistry() must be called before use");
}
return registryDeps;
}
/** Reset storeRegistry deps (mainly for tests). */
export function resetStoreRegistry(): void {
registryDeps = null;
}
/** Set the current app-level user (the polyfill's notion of "who am I"). */
export function setCurrentUser(id: PrincipalId | null): void {
currentUser = id;
+285
View File
@@ -0,0 +1,285 @@
/**
* storeRegistry — resolves (account, scope) → document NURI.
*
* **STOPGAP / polyfill-era.** Emulates the target infrastructure — where each
* user owns their own public/protected/private stores — on top of ONE shared
* wallet. It creates one document per (account × scope) inside that shared
* wallet (via the `docs.docCreate` primitive), so the `scope`
* (`public|protected|private`) is a LOGICAL attribute tracked here, NOT a
* physical NextGraph store. Isolation is enforced by the app layer + the
* emulated cap registry, not by crypto.
*
* The mapping (account → its 3 document NURIs) is the **sharedWalletShim**,
* persisted as RDF in the shared wallet's private store (the anchor, always
* known from the session). That makes login cross-device: another device
* opening the same wallet reads the same shim and finds the same accounts.
*
* ── GENERIC by construction ──────────────────────────────────────────────
* This module knows ONLY the three native scopes. It does NOT know any
* application entity kind (event, meeting-point, profile, …). The consumer
* maps its entities to a scope and calls `createEntityDoc(scope)` /
* `listEntityDocs(scope)` with the resulting native scope. Zero domain here.
*
* ── WHAT DISAPPEARS AT MIGRATION ─────────────────────────────────────────
* At the real multi-store migration the shim vanishes entirely: `(account,
* scope)` maps to the user's REAL store NURI instead of a document in the
* shared wallet, `docCreate` targets the real per-user store, and the
* per-scope index document (the store-container emulation) is replaced by the
* store itself. The consumer-facing surface (`createEntityDoc`,
* `listEntityDocs`, resolvers) is designed to survive that swap unchanged.
*
* All NextGraph I/O routes through the T01.a `docs` primitive (real injected
* `ng`), so this module imports **no** `@ng-org` package.
*/
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
import { getStoreRegistryDeps } from "./polyfill";
import type { Nuri, Scope } from "./types";
// --- sharedWalletShim model ----------------------------------------------
/** One account's three scope-document NURIs, as recorded in the shim. */
export interface AccountRecord {
username: string;
docPublic: Nuri;
docProtected: Nuri;
docPrivate: Nuri;
}
const SHIM = "urn:ng-eventually:shim";
const P = {
type: `${SHIM}:Account`,
username: `${SHIM}:username`,
docPublic: `${SHIM}:docPublic`,
docProtected: `${SHIM}:docProtected`,
docPrivate: `${SHIM}:docPrivate`,
contains: `${SHIM}:contains`, // scope-index → entity document NURI
} as const;
// Fixed subject of the per-(account×scope) index document. The index doc plays
// the role of the future store-container: it lists the NURIs of the entity
// documents (one per entity) that live "in" that scope.
const INDEX_SUBJECT = `${SHIM}:index`;
function accountSubject(username: string): string {
return `${SHIM}:account:${normalize(username)}`;
}
// --- session / normalization access (injected by the consumer) ------------
/** Minimal session shape the registry needs — provided by the consumer. */
export interface RegistrySession {
sessionId: string;
/** The shared wallet's private store id — the shim anchor. */
privateStoreId: string;
}
function normalize(username: string): string {
return getStoreRegistryDeps().normalizeUser(username);
}
async function session(): Promise<RegistrySession> {
return getStoreRegistryDeps().getSession();
}
/** The shim lives in the shared wallet's private store (always-known anchor). */
async function anchorNuri(): Promise<Nuri> {
const s = await session();
return `did:ng:${s.privateStoreId}`;
}
// --- cache ----------------------------------------------------------------
// In-memory cache of the shim, keyed by normalized username.
let cache: Map<string, AccountRecord> | null = null;
/** Reset cache (e.g. after switching the shared wallet). Mostly for tests. */
export function resetRegistryCache(): void {
cache = null;
}
// --- SPARQL result helpers ------------------------------------------------
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
function readBindings(result: unknown): Array<Record<string, { value: string }>> {
if (!result) return [];
const anyRes = result as {
results?: { bindings?: Array<Record<string, { value: string }>> };
};
if (Array.isArray(result)) return result as Array<Record<string, { value: string }>>;
if (anyRes.results?.bindings) return anyRes.results.bindings;
return [];
}
function bindingValue(row: Record<string, { value: string }>, key: string): string {
return row[key]?.value ?? "";
}
// --- shim load / account bootstrap ----------------------------------------
/** Load all accounts from the shim into the cache. */
export async function loadShim(): Promise<Map<string, AccountRecord>> {
if (cache) return cache;
const s = await session();
const anchor = await anchorNuri();
const query = `
SELECT ?username ?docPublic ?docProtected ?docPrivate WHERE {
GRAPH <${anchor}> {
?acc a <${P.type}> ;
<${P.username}> ?username ;
<${P.docPublic}> ?docPublic ;
<${P.docProtected}> ?docProtected ;
<${P.docPrivate}> ?docPrivate .
}
}`;
const map = new Map<string, AccountRecord>();
try {
const result = await sparqlQuery(s.sessionId, query, undefined, anchor);
for (const row of readBindings(result)) {
const username = bindingValue(row, "username");
if (!username) continue;
map.set(normalize(username), {
username,
docPublic: bindingValue(row, "docPublic"),
docProtected: bindingValue(row, "docProtected"),
docPrivate: bindingValue(row, "docPrivate"),
});
}
} catch (error) {
console.error("[storeRegistry] loadShim failed:", error);
}
cache = map;
return map;
}
/** All known accounts (from the shim). */
export async function allAccounts(): Promise<AccountRecord[]> {
return [...(await loadShim()).values()];
}
/** Create one graph document in the shared wallet's private store (→ a NURI). */
async function createDoc(): Promise<Nuri> {
const s = await session();
// crdt="Graph" (RDF/SPARQL/ORM), class="data:graph", destination="store",
// store_repo=undefined → shared wallet's private store.
return docCreate(s.sessionId, "Graph", "data:graph", "store", undefined);
}
/**
* Ensure an account exists in the shim, creating its 3 scope documents on
* first sight. Idempotent — returns the existing record if already present.
*/
export async function ensureAccount(username: string): Promise<AccountRecord> {
const map = await loadShim();
const key = normalize(username);
const existing = map.get(key);
if (existing) return existing;
const [docPublic, docProtected, docPrivate] = await Promise.all([
createDoc(),
createDoc(),
createDoc(),
]);
const record: AccountRecord = { username, docPublic, docProtected, docPrivate };
const s = await session();
const anchor = await anchorNuri();
const subj = accountSubject(username);
const update = `
INSERT DATA {
GRAPH <${anchor}> {
<${subj}> a <${P.type}> ;
<${P.username}> "${username}" ;
<${P.docPublic}> "${docPublic}" ;
<${P.docProtected}> "${docProtected}" ;
<${P.docPrivate}> "${docPrivate}" .
}
}`;
try {
await sparqlUpdate(s.sessionId, update, anchor);
} catch (error) {
console.error("[storeRegistry] ensureAccount persist failed:", error);
}
map.set(key, record);
return record;
}
// --- resolvers ------------------------------------------------------------
/** The index document NURI of an account for a scope (the store-container). */
function indexDocOf(record: AccountRecord, scope: Scope): Nuri {
return scope === "public"
? record.docPublic
: scope === "protected"
? record.docProtected
: record.docPrivate;
}
/**
* NURI of the document where `username` writes GROUPED entities of `scope`
* (e.g. participations, profile — no per-entity document / no inbox needed).
* For per-entity scopes use {@link createEntityDoc} instead.
*/
export async function resolveWriteGraph(username: string, scope: Scope): Promise<Nuri> {
const record = await ensureAccount(username);
return indexDocOf(record, scope);
}
/** NURIs of every account's document for `scope` (read fan-out). */
export async function resolveReadGraphs(scope: Scope): Promise<Nuri[]> {
const accounts = await allAccounts();
return accounts.map((a) => indexDocOf(a, scope));
}
// --- per-entity documents + per-scope index -------------------------------
/**
* Create a dedicated document for ONE entity — mirrors the target, where each
* such entity is its own document/repo (addressable, future inbox). The new
* document's NURI is appended to the account's scope index document (the
* store-container). Returns the entity document NURI (use it as `@graph`).
*/
export async function createEntityDoc(username: string, scope: Scope): Promise<Nuri> {
const record = await ensureAccount(username);
const indexDoc = indexDocOf(record, scope);
const entityNuri = await createDoc();
const s = await session();
try {
await sparqlUpdate(
s.sessionId,
`INSERT DATA { GRAPH <${indexDoc}> { <${INDEX_SUBJECT}> <${P.contains}> "${entityNuri}" } }`,
indexDoc,
);
} catch (error) {
console.error("[storeRegistry] createEntityDoc index append failed:", error);
}
return entityNuri;
}
/**
* Every entity document NURI of `scope`, across all accounts — the read
* fan-out for per-entity scopes. Reads each account's scope index document and
* unions the contained NURIs. Use as `useShape(shape, { graphs })`.
*/
export async function listEntityDocs(scope: Scope): Promise<Nuri[]> {
const accounts = await allAccounts();
const s = await session();
const out: Nuri[] = [];
for (const a of accounts) {
const indexDoc = indexDocOf(a, scope);
try {
const res = await sparqlQuery(
s.sessionId,
`SELECT ?e WHERE { GRAPH <${indexDoc}> { <${INDEX_SUBJECT}> <${P.contains}> ?e } }`,
undefined,
indexDoc,
);
for (const row of readBindings(res)) {
const v = bindingValue(row, "e");
if (v) out.push(v);
}
} catch (error) {
console.error("[storeRegistry] listEntityDocs read failed:", error);
}
}
return out;
}