fix: le filtre de lecture couvre l-enveloppe que le SDK renvoie vraiment
Le `useShape` publie enveloppait le resultat du hook dans le filtre ecrit pour une COLLECTION. Depuis que `@ng-org/orm` renvoie un `UseShapeResult` autour du set, `[...resultat]` et `.size` levaient, et `data`, `promise` et `subscription` revenaient BRUTS - chacun tous les objets de toutes les identites, sans que rien ne casse. Aucun faux de ce depot ne renvoyait la forme livree, donc aucune garde ne pouvait le voir. Deux vues au lieu d-une : celle de la collection, et celle de l-enveloppe que `surface/use-shape.ts` applique. `data` et `promise` sont filtres, `subscription` est refuse (c-est le handle de l-ORM, que cette surface ne publie pas), les deux commandes de page passent. Le defaut par TYPE et non par une liste de noms : un primitif ne peut atteindre aucun objet et passe, un objet ou une fonction refuse tant qu-il n-existe pas de forme filtree ici. C-est pourquoi `isLoading` n-est nomme nulle part, et pourquoi un membre qu-une version suivante ajoutera est deja tranche. `size` et `length` comptent ce que le porteur peut lire. `UseShapeLike` etait `(...args: any[]) => any`, ce qui est la raison pour laquelle les deux typechecks restaient a 0 pendant que la fuite etait ouverte : aucune declaration ne disait ce que le hook rend. Il rend `ShapeResult`, releve sur le `.d.ts` installe, et le vrai `reactUseShape` y est assignable sans cast. Le faux partage `test/use-shape-fake.ts` renvoie la forme livree et c-est celui que les deux gardes injectent - unitaire et e2e. La garantie du contrat et le test qui l-epingle arrivent ensemble ; `1.0.0-dev.11`, majeur.
This commit is contained in:
@@ -73,6 +73,50 @@ export function filterReadable<T>(items: Iterable<T>, caps: CapRegistry): T[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this value NOT reach an item, whatever it turns out to be?
|
||||
*
|
||||
* The one question a filter can ask about a member it has never seen. A primitive holds no
|
||||
* reference to anything, so no item is reachable through it and it may pass. An object or a
|
||||
* function may hold one - directly, behind a promise, behind a getter, behind a whole
|
||||
* subscription - so it may not pass unless this file has a filtered form of it.
|
||||
*
|
||||
* `null` is a primitive in the model and `typeof null` is `"object"`, so it is named.
|
||||
*/
|
||||
function cannotReachAnItem(v: unknown): boolean {
|
||||
return v === null || (typeof v !== "object" && typeof v !== "function");
|
||||
}
|
||||
|
||||
/** What a refused member says. One wording for both shapes of refusal, so that a report of
|
||||
* either is greppable as the same event. */
|
||||
function refusalOf(prop: string | symbol, where: string): Error {
|
||||
return new Error(
|
||||
`[ng-eventually] read filter: \`${String(prop)}\` is not filtered, so reading it ` +
|
||||
"would return items this identity may not read. Add it to " +
|
||||
`\`emulated-verifier/read-filter.ts\` (${where}), filtered \u2014 do not bypass the view.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer for a member this file does not name: decided by the TYPE of what is behind it,
|
||||
* never by its name. See the note on {@link makeReadFilteredView}.
|
||||
*
|
||||
* A function is refused AT THE CALL rather than at the read, which is not politeness: a
|
||||
* function value is read by things that never call it - a logger, a devtools inspection, a
|
||||
* `typeof` guard - and refusing those would be refusing a question nobody asked. An object
|
||||
* has no such later moment, so it is refused at the read.
|
||||
*/
|
||||
function answerUnnamedMember(target: object, prop: string | symbol, where: string): unknown {
|
||||
const v = Reflect.get(target, prop, target);
|
||||
if (cannotReachAnItem(v)) return v;
|
||||
if (typeof v === "function") {
|
||||
return () => {
|
||||
throw refusalOf(prop, where);
|
||||
};
|
||||
}
|
||||
throw refusalOf(prop, where);
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-filtered VIEW over a reactive set (a `DeepSignalSet`, or any Set-like).
|
||||
*
|
||||
@@ -95,8 +139,20 @@ export function filterReadable<T>(items: Iterable<T>, caps: CapRegistry): T[] {
|
||||
* hits it exactly what to do: add the member here, filtered. A boundary whose unknown
|
||||
* cases leak is not a boundary.
|
||||
*
|
||||
* Everything that is not a function passes through untouched (`size` is handled above):
|
||||
* a plain property carries no items.
|
||||
* ── Why the default is decided by TYPE, and not by a list of names ────────
|
||||
* Until 2026-09-24 that reasoning was applied to FUNCTION members alone: everything else
|
||||
* was forwarded raw, on the recorded ground that "a plain property carries no items". That
|
||||
* ground was never a property of the model, only of the object that happened to be wrapped
|
||||
* — and the moment the wrapped object changed, three plain properties of it each carried
|
||||
* every identity's items ({@link makeReadFilteredShapeResult}). A boundary that is safe for
|
||||
* the shape in front of it and unsafe for the next one is not a boundary either.
|
||||
*
|
||||
* So the default is {@link cannotReachAnItem}, which asks of the VALUE what no version
|
||||
* number can change: a primitive cannot hold a reference to anything, so it cannot reach an
|
||||
* item and passes untouched — `size`, `isLoading`, a count, a flag, a name. An object or a
|
||||
* function can, so it refuses unless this file has a filtered form of it. That is why
|
||||
* nothing below names `isLoading`, and why a member nobody has seen yet is already decided:
|
||||
* if it carries items it is an object or a function, and both refuse.
|
||||
*/
|
||||
// @provenance makeReadFilteredView kind=aligned level=1 ref=engine/verifier/src/verifier.rs:load_repo_from_read_cap — the same filter as a live view over a reactive set; removed at migration
|
||||
export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry): S {
|
||||
@@ -110,7 +166,13 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
|
||||
return new Proxy(set, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === Symbol.iterator) return function* () { yield* kept(target); };
|
||||
if (prop === "size") return kept(target).length;
|
||||
// HOW MANY, under both spellings. `data` is a `DeepSignalSet` for an unordered read
|
||||
// and an array-like `DeepSignal<ReadOnlyArray>` once the config asks for an
|
||||
// `orderBy`, and `length` is a number - so it used to pass as a primitive and
|
||||
// publish the UNFILTERED count, which is showing more than the holder may read.
|
||||
// Each is answered only when the target carries it, so the view keeps the shape of
|
||||
// what it wraps instead of growing a member the collection does not have.
|
||||
if ((prop === "size" || prop === "length") && prop in target) return kept(target).length;
|
||||
// A Set yields the item for both halves of a `[key, value]` pair; `DeepSignalSet`
|
||||
// follows the same shape, so `keys`/`values`/`entries` are the Set contract.
|
||||
if (prop === "values" || prop === "keys") return () => kept(target)[Symbol.iterator]();
|
||||
@@ -176,18 +238,113 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
|
||||
);
|
||||
}
|
||||
|
||||
const v = Reflect.get(target, prop, target);
|
||||
if (typeof v !== "function") return v;
|
||||
// UNKNOWN function member: refuse rather than forward. See the header — forwarding
|
||||
// is a silent leak, and this view's one job is that it cannot show more than the
|
||||
// holder may read.
|
||||
return () => {
|
||||
throw new Error(
|
||||
`[ng-eventually] read filter: \`${String(prop)}\` is not filtered, so calling it ` +
|
||||
"would return items this identity may not read. Add it to " +
|
||||
"`emulated-verifier/read-filter.ts`, filtered — do not bypass the view.",
|
||||
);
|
||||
};
|
||||
// ANY OTHER MEMBER, function or not: refuse rather than forward, unless the value
|
||||
// behind it cannot reach an item at all. See the header — forwarding is a silent
|
||||
// leak, and this view's one job is that it cannot show more than the holder may read.
|
||||
// An index on an array-like `data` lands here and is refused: it hands back a raw
|
||||
// item, and there is no projection of it that this file would not have to invent.
|
||||
return answerUnnamedMember(target, prop, "the collection view");
|
||||
},
|
||||
}) as S;
|
||||
}
|
||||
|
||||
/**
|
||||
* One filtered view per underlying collection, so that reading `.data` twice hands back the
|
||||
* SAME reference. A caller puts that value in a dependency array; a fresh proxy on every
|
||||
* read would make it look like new data on every render. Keyed weakly on the collection the
|
||||
* SDK handed us, and rebuilt if the registry it was filtered against is no longer the one in
|
||||
* force, so a view can never answer from a keyring that has been replaced.
|
||||
*/
|
||||
const filteredCollections = new WeakMap<object, { caps: CapRegistry; view: object }>();
|
||||
|
||||
/** The read-filtered view of one item-carrying value, memoized. */
|
||||
function filteredCollection(value: object, caps: CapRegistry): object {
|
||||
const known = filteredCollections.get(value);
|
||||
if (known !== undefined && known.caps === caps) return known.view;
|
||||
const view = makeReadFilteredView(value, caps);
|
||||
filteredCollections.set(value, { caps, view });
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Same, for the promise that resolves to one: the filtering happens on what it resolves to. */
|
||||
const filteredPromises = new WeakMap<object, { caps: CapRegistry; view: Promise<unknown> }>();
|
||||
|
||||
function filteredPromise(value: PromiseLike<unknown>, caps: CapRegistry): Promise<unknown> {
|
||||
const known = filteredPromises.get(value as object);
|
||||
if (known !== undefined && known.caps === caps) return known.view;
|
||||
const view = Promise.resolve(value).then((d) =>
|
||||
cannotReachAnItem(d) ? d : filteredCollection(d as object, caps),
|
||||
);
|
||||
filteredPromises.set(value as object, { caps, view });
|
||||
return view;
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-filtered VIEW over the RESULT the SDK's `useShape` hands back - the envelope, not
|
||||
* the collection inside it.
|
||||
*
|
||||
* ── Why there are two views and not one ───────────────────────────────────
|
||||
* `@ng-org/orm@0.1.2-alpha.21`'s React `useShape` returns a `UseShapeResult`, an object
|
||||
* whose members are of two different kinds: some carry the items, some are the request's
|
||||
* own state. Until 2026-09-24 this package wrapped that envelope in
|
||||
* {@link makeReadFilteredView}, which is written for a collection - so `[...result]` and
|
||||
* `.size` threw (an envelope is not iterable), while `.data`, `.promise` and
|
||||
* `.subscription` were each returned RAW and each handed back every identity's items. One
|
||||
* object cannot be both, so there are two views and this is the one `surface/use-shape.ts`
|
||||
* applies.
|
||||
*
|
||||
* ── What each member answers, and why nothing here names `isLoading` ──────
|
||||
* The three that carry the items are named and filtered; the two that command a page are
|
||||
* named and forwarded, because they return `void` and load rows into a collection that is
|
||||
* only ever seen through the filter. `isLoading` is NOT named: it is a boolean, and the
|
||||
* default already passes anything that cannot reach an item. That is the whole point of
|
||||
* deciding by type - a member added by a later version is already decided, and a name list
|
||||
* would have been another thing to get right on the next bump.
|
||||
*
|
||||
* `subscription` is REFUSED rather than wrapped, and the reason is this surface's own: it
|
||||
* is the SDK's `RdfOrmSubscription`, which this package deliberately does not publish
|
||||
* because one wallet hosts every identity here, so an unwrapped read through it returns
|
||||
* every identity's objects. It reaches the items three separate ways - `signalObject`,
|
||||
* `readyPromise`, and the raw `{adds, updates, removes}` handed to `addChangeListener` -
|
||||
* and a wrapper of it would be a second published surface to keep honest. Refusing is
|
||||
* loud, and what it refuses is the one member the contract already says is not published.
|
||||
*/
|
||||
// @provenance makeReadFilteredShapeResult kind=aligned level=3 ref=sdk/js/orm/src/frontendAdapters/react/useShape.ts:useShape — the same capability filter, over the result envelope that hook returns rather than over the collection inside it; removed at migration
|
||||
export function makeReadFilteredShapeResult<R extends object>(result: R, caps: CapRegistry): R {
|
||||
return new Proxy(result, {
|
||||
get(target, prop) {
|
||||
// THE ITEMS, under the three names alpha.21 offers them. Each is filtered rather
|
||||
// than refused: a holder reading their own data through whichever member the SDK
|
||||
// gives them is exactly what this view exists to keep working.
|
||||
if (prop === "data") {
|
||||
const d = Reflect.get(target, prop, target);
|
||||
return cannotReachAnItem(d) ? d : filteredCollection(d as object, caps);
|
||||
}
|
||||
if (prop === "promise") {
|
||||
const p = Reflect.get(target, prop, target);
|
||||
return cannotReachAnItem(p) ? p : filteredPromise(p as PromiseLike<unknown>, caps);
|
||||
}
|
||||
if (prop === "subscription") {
|
||||
const s = Reflect.get(target, prop, target);
|
||||
// `undefined` when the caller passed no config: nothing was opened, so there is
|
||||
// nothing to refuse, and answering the SDK's own value keeps that case honest.
|
||||
if (cannotReachAnItem(s)) return s;
|
||||
throw new Error(
|
||||
"[ng-eventually] read filter: `subscription` hands back NextGraph's own " +
|
||||
"`RdfOrmSubscription`, which reads and writes with no capability filter at all " +
|
||||
"- one wallet hosts every identity here, so it would return every identity's " +
|
||||
"objects. This package does not publish it. Read through `data`, `promise`, or " +
|
||||
"`useShapeQuery`.",
|
||||
);
|
||||
}
|
||||
// THE PAGE COMMANDS. They return `void` and yield nothing, so they forward, exactly
|
||||
// as `add`/`delete`/`clear` do on the collection view. What they load lands in the
|
||||
// raw collection, which is only ever read back through the filter above.
|
||||
if (prop === "nextPage" || prop === "previousPage") {
|
||||
const fn = Reflect.get(target, prop, target);
|
||||
return typeof fn === "function" ? fn.bind(target) : fn;
|
||||
}
|
||||
return answerUnnamedMember(target, prop, "the result envelope");
|
||||
},
|
||||
}) as R;
|
||||
}
|
||||
|
||||
@@ -78,9 +78,61 @@ export type PrincipalId = string;
|
||||
// @provenance NgLike kind=invention level=none ref=none — nothing upstream is injected; the type exists only to describe what `configure` receives
|
||||
export type NgLike = Record<string, any>;
|
||||
|
||||
/** Loose shape of `@ng-org/orm`'s `useShape` (a generic hook). */
|
||||
// @provenance UseShapeLike kind=invention level=none ref=none — same, for the injected `useShape`
|
||||
export type UseShapeLike = (...args: any[]) => any;
|
||||
/**
|
||||
* What the SDK's `useShape` hands back - the members this package reads off the result
|
||||
* envelope, and not one more. Same discipline as {@link OrmSubscriptionHandle}.
|
||||
*
|
||||
* NOT published: an application never writes this type down, it reads the value. What is
|
||||
* published is the call that returns one.
|
||||
*
|
||||
* `@ng-org/orm@0.1.2-alpha.21` calls it `UseShapeResult` and declares it in
|
||||
* `dist/frontendAdapters/react/useShape.d.ts` - which no subpath of the package EXPORTS
|
||||
* (`.` is `core.d.ts`, `/react` re-exports the hook alone, `/all` re-exports the config
|
||||
* types alone), so it cannot be imported and is restated here. The three item-carrying
|
||||
* members are `unknown` on purpose: each is read through the capability filter, never off
|
||||
* this type, and narrowing them here would make the real hook unassignable to
|
||||
* {@link UseShapeLike} for a difference no reader of this package can act on.
|
||||
*
|
||||
* The two page commands are OPTIONAL because upstream `WithMaybePagination` `Omit`s them
|
||||
* unless the config asks for a `pageSize`, and the shipped hook sets them to `undefined`
|
||||
* outside a paginated mode.
|
||||
*/
|
||||
// @provenance ShapeResult kind=aligned level=3 ref=sdk/js/orm/src/frontendAdapters/react/useShape.ts:useShape - the result type of the cited hook, restated because the package exports it from no subpath; the cited revision returns a bare `DeepSignalSet` and the build returns this envelope around one, which is the gap the anchor records
|
||||
export interface ShapeResult {
|
||||
/** The requested objects once loaded, `undefined` while they are not. A `DeepSignalSet`
|
||||
* for an unordered read, an array-like `DeepSignal<ReadOnlyArray>` once the config asks
|
||||
* for an `orderBy`. Upstream: "the value returned by `RdfOrmSubscription.signalObject`". */
|
||||
readonly data: unknown;
|
||||
/** Resolves to the same data once it has arrived; `undefined` when the config was. */
|
||||
readonly promise: unknown;
|
||||
/** The `RdfOrmSubscription` underneath. This package refuses to hand it on - see
|
||||
* `emulated-verifier/read-filter.ts` - and declares it here because refusing a member
|
||||
* requires knowing it is there. */
|
||||
readonly subscription: unknown;
|
||||
/** Whether the initial payload is still outstanding. */
|
||||
readonly isLoading: boolean;
|
||||
/** Load the next page. Present only under a paginated config. */
|
||||
readonly nextPage?: (() => void) | undefined;
|
||||
/** Load the previous page. Present only under `maxActivePages`. */
|
||||
readonly previousPage?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of `@ng-org/orm`'s React `useShape`, the hook an application injects at
|
||||
* {@link configure}.
|
||||
*
|
||||
* It was `(...args: any[]) => any` until 2026-09-24, and that is not a detail: an `any`
|
||||
* return means no declaration anywhere in this package said what the injected hook hands
|
||||
* back, so the prose said "a bare `DeepSignalSet`" - true of `alpha.18`, false of the
|
||||
* `alpha.21` this package builds against - and nothing could contradict it. The type now
|
||||
* says it, and the real `reactUseShape` is assignable to it with no wrapper and no cast
|
||||
* (pinned by `test/use-shape.test.ts`).
|
||||
*/
|
||||
// @provenance UseShapeLike kind=invention level=none ref=none — nothing upstream is injected; the type exists only to describe what `configure` receives, here for the ORM's React hook
|
||||
export type UseShapeLike = <T extends BaseType>(
|
||||
shapeType: ShapeType<T>,
|
||||
conf: RdfOrmConfig<T> | string | undefined,
|
||||
) => ShapeResult;
|
||||
|
||||
/**
|
||||
* The shape query in force, as `surface/use-shape-query.ts` reads it - the four members
|
||||
@@ -121,8 +173,10 @@ export interface OrmSubscriptionHandle {
|
||||
*
|
||||
* An application injects the real one (`ormSubscription: RdfOrmSubscription.getOrCreate`);
|
||||
* it is assignable to this as it stands, with no wrapper and no cast. `useShape` is not a
|
||||
* substitute: it is React's hook, it returns a bare `DeepSignalSet`, and a set cannot
|
||||
* carry the request state `ShapeQuery` publishes.
|
||||
* substitute: it is React's hook, so it can only be called from a render, and what it
|
||||
* returns carries the ORM's own two states and not the three `ShapeQuery` publishes - an
|
||||
* `isLoading` that does not tell a refused read from a pending one, and no error state at
|
||||
* all.
|
||||
*/
|
||||
// @provenance OrmSubscriptionLike kind=invention level=none ref=none - nothing upstream is injected; the type exists only to describe what `configure` receives, here for the ORM's own `getOrCreate`
|
||||
export type OrmSubscriptionLike = <T extends BaseType>(
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
/**
|
||||
* Wrapped `useShape`: same signature as `@ng-org/orm`. Once the cap emulation is
|
||||
* in force, the returned set is a read-filtered VIEW (only items in documents the
|
||||
* current holder has the ReadCap of); before the first cap is issued it passes the
|
||||
* real set through unchanged. At migration the filtering disappears — the broker
|
||||
* only delivers documents whose cap the wallet holds.
|
||||
* Wrapped `useShape`: the same call the SDK's React hook offers, and the same two
|
||||
* arguments. Once the cap emulation is in force, what comes back is a read-filtered VIEW
|
||||
* over the RESULT ENVELOPE the hook returns - `data` and `promise` carry only items in
|
||||
* documents the current holder has the `ReadCap` of, `subscription` is refused, and the
|
||||
* page commands and the load state pass through. Before the first cap is issued the real
|
||||
* result passes through unchanged. At migration the filtering disappears: the broker only
|
||||
* delivers documents whose cap the wallet holds.
|
||||
*
|
||||
* ── What changed on 2026-09-24, and why it was invisible ──────────────────
|
||||
* This wrapped the result in the COLLECTION view (`makeReadFilteredView`), which is
|
||||
* written for a `DeepSignalSet`. That was right while the hook returned one - `alpha.18` -
|
||||
* and wrong the moment `@ng-org/orm@0.1.2-alpha.21` started returning a `UseShapeResult`
|
||||
* around it. Against that shape the collection view threw on `[...result]` and `.size`
|
||||
* (an envelope is not iterable) and returned `data`, `promise` and `subscription` RAW,
|
||||
* each of them every identity's items. Nothing failed, because no fake in this repository
|
||||
* returned the shape the SDK ships.
|
||||
*/
|
||||
|
||||
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
|
||||
import type { RdfOrmConfig } from "@ng-org/orm/all";
|
||||
import { getConfig, getCaps } from "../shared-wallet/bootstrap";
|
||||
import { makeReadFilteredView } from "../emulated-verifier/read-filter";
|
||||
import { makeReadFilteredShapeResult } from "../emulated-verifier/read-filter";
|
||||
import type { ShapeResult } from "../model/types";
|
||||
|
||||
// @provenance useShape kind=passthrough level=3 ref=sdk/js/orm/src/frontendAdapters/react/useShape.ts:useShape — forwards to the injected real hook; the read-filtered view emulates cryptographic isolation and the generic `T` is widened to `unknown`
|
||||
export function useShape(shapeType: unknown, scope: unknown): unknown {
|
||||
const set = getConfig().useShape(shapeType, scope) as object;
|
||||
// @provenance useShape kind=passthrough level=3 ref=sdk/js/orm/src/frontendAdapters/react/useShape.ts:useShape — forwards to the injected real hook, with its two arguments; the read-filtered view over the result envelope emulates cryptographic isolation
|
||||
export function useShape<T extends BaseType>(
|
||||
shapeType: ShapeType<T>,
|
||||
conf: RdfOrmConfig<T> | string | undefined,
|
||||
): ShapeResult {
|
||||
const result = getConfig().useShape(shapeType, conf);
|
||||
const caps = getCaps();
|
||||
if (!caps.isEnforcing()) return set; // no cap issued yet → passthrough
|
||||
return makeReadFilteredView(set, caps);
|
||||
if (!caps.isEnforcing()) return result; // no cap issued yet → passthrough
|
||||
return makeReadFilteredShapeResult(result, caps);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user