fix: le descripteur d-un membre repond ce que le lire repond
Trouve en rejouant l-adversaire sur le correctif precedent, et mesure : `Object.getOwnPropertyDescriptor(resultat, "data")` rendait la collection BRUTE, et `(..., "subscription")` le handle que `get` refuse. Cet appel ne passe pas par le piege `get` - ni l-etalement, ni `Object.entries`, ni `JSON.stringify`, ni une inspection console. Une deuxieme porte sur la meme piece, ouverte par un piege que personne n-avait ecrit. Le meme trou existait sur la vue de collection pour la trappe dunder. La forme ordonnee dit combien d-objets elle porte a QUATRE endroits : `length`, `Object.keys`, `in`, et le descripteur de chaque index. Un seul etait filtre, donc le compte de ce que le porteur ne peut pas lire ressortait par les trois autres, et le descripteur rendait les objets eux-memes. Les quatre pieges appellent maintenant une seule fonction `read`, pas trois copies de la regle : une frontiere faite de `switch` paralleles est une frontiere qui derive. Et l-index nomme une position dans ce qui est LISIBLE, donc la forme ordonnee sert son porteur au lieu de refuser.
This commit is contained in:
@@ -260,7 +260,7 @@ Where a call must first find out whether something already exists — a document
|
||||
|
||||
**TWO COPIES OF THIS PACKAGE IN ONE APPLICATION MISBEHAVE SILENTLY, and nothing here can detect it.** What this package remembers it keeps in the package itself, not in any handle you hold: which documents are subscribed and who is listening for them, which identity the session settled on, which documents are open. None of that is shared between two copies. An application that ends up with two therefore runs two of everything — a document subscribed through one copy is invisible to the other, so changes to it simply never arrive; and the identity settled in one is not the identity the other acts as, so the same call writes as one user or the other depending on which copy it reached. Nothing raises, nothing warns, and no call can report it: from every entry point a second copy is indistinguishable from the first, and the symptoms surface far from the cause as missing updates and writes attributed to the wrong person. This is a packaging property, not a behaviour to code around — **declare this package once, as one dependency of the application**, and let your own code and every library built on it resolve to that copy. A library that builds on this package should declare it a *peer* rather than a dependency for exactly this reason, so that the application remains the one place it is named.
|
||||
|
||||
**No unfiltered read through `useShape`, and no way round it through the object it hands back.** What the call returns is NextGraph's own result, filtered: `data` and `promise` carry only items in documents you hold the key for, `isLoading` and the two page commands pass through, and `subscription` - the ORM's own handle, which reads and writes with no capability filter at all - is refused out loud rather than handed on. Any other member of that object is refused too unless what is behind it is a plain scalar, which is how a member a later `@ng-org/orm` adds is decided before anyone has seen it: whatever can carry an item is an object or a function, and both refuse. Inside `data`, every member that yields items is filtered, mutations pass through, `size` and `length` count what you may read and not what is there, and an index into an ordered result is refused rather than guessed at. A document reached through that view alone, read nowhere else first, does not appear.
|
||||
**No unfiltered read through `useShape`, and no way round it through the object it hands back.** What the call returns is NextGraph's own result, filtered: `data` and `promise` carry only items in documents you hold the key for, `isLoading` and the two page commands pass through, and `subscription` - the ORM's own handle, which reads and writes with no capability filter at all - is refused out loud rather than handed on. Any other member of that object is refused too unless what is behind it is a plain scalar, which is how a member a later `@ng-org/orm` adds is decided before anyone has seen it: whatever can carry an item is an object or a function, and both refuse. Inside `data`, every member that yields items is filtered, mutations pass through, and an ordered result addresses and counts the items you may read: `size`, `length`, an index, `Object.keys`, `in` and `JSON.stringify` all agree, and none of them says how many you may not. **Reading a member's DESCRIPTOR answers exactly what reading the member answers**, which is not a detail: `Object.getOwnPropertyDescriptor` - and the spread, `Object.entries` and console inspection built on it - reads a property without going through the view's own read, so a refused member throws there too rather than handing its value over. A document reached through that view alone, read nowhere else first, does not appear.
|
||||
|
||||
**`getObjects` does not fetch a public store's key for you either**, for the same reason as the two shape reads below: it goes through the same ORM query and spends no round-trip asking. Read the document once by naming it — `docs.sparqlQuery` anchored on it — and it appears.
|
||||
|
||||
@@ -290,7 +290,8 @@ What each level means here, in this package's own terms:
|
||||
- **`subscription` is REFUSED where it used to be handed over.** It is the ORM's own `RdfOrmSubscription`, which reads and writes with no capability filter at all and which `## Non-guarantees` has always said this package does not publish. Reading it now throws, naming itself. `data`, `promise`, `isLoading` and the two page commands are what remains, and they are what a caller needs: the SDK's own example destructures `data` and `isLoading` and nothing else.
|
||||
- **A member of that object that this package does not know is refused unless it is a plain scalar.** The rule is the value's TYPE, not its name: whatever can carry an item is an object or a function, so a member the next `@ng-org/orm` adds is already decided, while a flag or a count still passes. The previous rule - "anything that is not a function is harmless" - is precisely what let three members of the new shape through, so it has been replaced rather than extended with three names.
|
||||
- **The two arguments are the SDK's own types**, where the signature took `unknown` for both. `useShape(shapeType: ShapeType<T>, conf: RdfOrmConfig<T> | string | undefined)`, which is what the surface already said the pair was. A caller passing the SDK's shape and config compiles unchanged; one passing something else now does not, which is why this counts.
|
||||
- **`size` and `length` count what you may read.** An ordered config (`orderBy`) makes the collection an array, whose count is a number - and a number used to pass through untouched, publishing how many items you may NOT read. Indexing that array (`data[0]`) is refused rather than projected.
|
||||
- **`size` and `length` count what you may read, and so does every other way of asking.** An ordered config (`orderBy`) makes the collection an array, which says how many items it holds in four places - `length`, `Object.keys`, `in`, and the descriptor of each index - and only the first was filtered. `data[0]` now names the first item you may read rather than the first the engine returned, and the positions past them do not exist.
|
||||
- **A member's DESCRIPTOR answers what reading it answers.** `Object.getOwnPropertyDescriptor(result, "data")` returned the collection UNFILTERED, and `...(result, "subscription")` the handle that reading it refuses: that call does not go through the view's own read. Neither does a spread, `Object.entries`, `JSON.stringify` or a console inspection, so all of them were reading round the filter. They now read through it - which also means `{...result}` and `console.log(result)` throw on `subscription`, where they used to print it. Log `data`.
|
||||
- **Unchanged**: the read still spends no round-trip asking a public store for a key, a document you have read nowhere else still does not appear, mutations of the live collection still pass through, and nothing about `useShapeQuery`, `getObjects` or `insertObject` moves.
|
||||
|
||||
`1.0.0-dev.10` is a **major**, and all of it is one rule: **"is this document open in this session" now has exactly one answer.** This package kept three records of that fact - a set of live bootstrap subscriptions, a per-document barrier state, and the subscription layer's own listeners - and the first two outlived the third. Only one remains, the state of the document's one real subscription; the other two are gone rather than reconciled, because two records of one fact can disagree and these did. Nothing is added, renamed or removed from the entry.
|
||||
|
||||
@@ -117,6 +117,43 @@ function answerUnnamedMember(target: object, prop: string | symbol, where: strin
|
||||
throw refusalOf(prop, where);
|
||||
}
|
||||
|
||||
/** `"0"`, `"12"` - an array index, in the canonical form a property key takes. */
|
||||
const INDEX = /^(0|[1-9][0-9]*)$/;
|
||||
|
||||
/**
|
||||
* The descriptor of a member, filtered the same way reading it is.
|
||||
*
|
||||
* `Object.getOwnPropertyDescriptor` and everything built on it - `Object.keys`,
|
||||
* `Object.entries`, a spread, `JSON.stringify`, a console inspection - read a property
|
||||
* WITHOUT going through the `get` trap. A view that filtered `get` alone therefore handed
|
||||
* the raw value straight back to whoever asked for the descriptor instead: measured
|
||||
* 2026-09-24 on the first fix of this file, where `getOwnPropertyDescriptor(result, "data")`
|
||||
* returned the unfiltered collection and `...(result, "subscription")` the ORM handle that
|
||||
* `get` refuses. A second door into the same room, opened by a trap nobody wrote.
|
||||
*
|
||||
* So the descriptor is answered by REREADING through the view: `read` is the very function
|
||||
* the `get` trap calls, passed in, so the two cannot drift apart. A member `get` refuses
|
||||
* throws here too, at the same place and with the same message.
|
||||
*/
|
||||
function filteredDescriptor(
|
||||
target: object,
|
||||
prop: string | symbol,
|
||||
read: () => unknown,
|
||||
): PropertyDescriptor | undefined {
|
||||
const d = Reflect.getOwnPropertyDescriptor(target, prop);
|
||||
if (d === undefined) return undefined;
|
||||
// An accessor carries no value of its own; the `get` trap is what answers it.
|
||||
if (!("value" in d)) return d;
|
||||
const value = read();
|
||||
if (Object.is(d.value, value)) return d;
|
||||
// A property that is neither configurable nor writable cannot be REPORTED with another
|
||||
// value without breaking the invariants a proxy is held to, and reporting the target's
|
||||
// would be the leak. Nothing on either shape this file wraps is one - a result object's
|
||||
// members and an array's indices are both - so this is a guard, not a path.
|
||||
if (d.configurable !== true && d.writable !== true) throw refusalOf(prop, "its descriptor");
|
||||
return { ...d, value };
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-filtered VIEW over a reactive set (a `DeepSignalSet`, or any Set-like).
|
||||
*
|
||||
@@ -139,6 +176,14 @@ function answerUnnamedMember(target: object, prop: string | symbol, where: strin
|
||||
* hits it exactly what to do: add the member here, filtered. A boundary whose unknown
|
||||
* cases leak is not a boundary.
|
||||
*
|
||||
* ── And `get` is not the only door ────────────────────────────────────────
|
||||
* Three traps answer the same question and all three have to give the same answer:
|
||||
* `get`, `getOwnPropertyDescriptor` (which `Object.keys`, a spread, `JSON.stringify` and
|
||||
* any console inspection go through, WITHOUT touching `get`) and, on the array form,
|
||||
* `has` and `ownKeys`. Each of them was forwarding to the target while `get` filtered.
|
||||
* They are wired to one `read` function below rather than to three copies of the rule,
|
||||
* because a boundary made of parallel switch statements is a boundary that drifts.
|
||||
*
|
||||
* ── 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
|
||||
@@ -163,87 +208,130 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
|
||||
for (const item of target as Iterable<unknown>) if (keep(item)) out.push(item);
|
||||
return out;
|
||||
};
|
||||
/**
|
||||
* Is the collection ARRAY-shaped? An `orderBy` config makes upstream's `signalObject` a
|
||||
* `DeepSignal<ReadOnlyArray>` instead of a `DeepSignalSet`, and an array is addressed by
|
||||
* INDEX and counts itself with `length`. Decided from the target once, so the view never
|
||||
* grows a member the collection it wraps does not have.
|
||||
*/
|
||||
const arrayLike = typeof (set as { length?: unknown }).length === "number";
|
||||
/** The index a key names, or `null` when it names no position in this collection. */
|
||||
const indexOf = (prop: string | symbol): number | null =>
|
||||
arrayLike && typeof prop === "string" && INDEX.test(prop) ? Number(prop) : null;
|
||||
|
||||
/** What the view answers for one member. The `get` trap and the descriptor trap BOTH
|
||||
* call this, so neither can answer what the other refuses. */
|
||||
const read = (target: object, prop: string | symbol, receiver: unknown): unknown => {
|
||||
if (prop === Symbol.iterator) return function* () { yield* kept(target); };
|
||||
// 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;
|
||||
// AN INDEX, on the array form. It names a position in the readable items, not in the
|
||||
// collection underneath: the two differ by exactly what this view removed.
|
||||
{
|
||||
const i = indexOf(prop);
|
||||
if (i !== null) return kept(target)[i];
|
||||
}
|
||||
// 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]();
|
||||
if (prop === "entries") return () => kept(target).map((i) => [i, i] as const)[Symbol.iterator]();
|
||||
if (prop === "forEach") {
|
||||
return (cb: (v: unknown, v2: unknown, s: unknown) => void) => {
|
||||
for (const item of kept(target)) cb(item, item, receiver);
|
||||
};
|
||||
}
|
||||
// `has(item)` is filtered, not forwarded: the caller already holds the item, so the
|
||||
// answer discloses nothing new — but upstream an unreadable item is never delivered
|
||||
// at all, so "yes it is in there" would be an answer the target cannot give.
|
||||
if (prop === "has") return (item: unknown) => keep(item) && (target as Set<unknown>).has(item);
|
||||
// The reactive-set extras: they iterate, so they must iterate the filtered items.
|
||||
// The list is `iteratorHelperKeys` from `@ng-org/alien-deepsignals` — an earlier
|
||||
// pass whitelisted half of it and threw on the rest, so a holder's calls on their
|
||||
// OWN data crashed (`toArray`, `reduce`, `first`…). Filtering is the answer for all
|
||||
// of them; refusing is only for what is not on this list.
|
||||
if (prop === "map") return (fn: (v: unknown, i: number) => unknown) => kept(target).map(fn);
|
||||
if (prop === "filter") return (fn: (v: unknown, i: number) => boolean) => kept(target).filter(fn);
|
||||
if (prop === "find") return (fn: (v: unknown, i: number) => boolean) => kept(target).find(fn);
|
||||
if (prop === "some") return (fn: (v: unknown, i: number) => boolean) => kept(target).some(fn);
|
||||
if (prop === "every") return (fn: (v: unknown, i: number) => boolean) => kept(target).every(fn);
|
||||
if (prop === "toArray") return () => kept(target);
|
||||
if (prop === "first") return () => kept(target)[0];
|
||||
if (prop === "take") return (n: number) => kept(target).slice(0, n);
|
||||
if (prop === "drop") return (n: number) => kept(target).slice(n);
|
||||
if (prop === "flatMap") return (fn: (v: unknown, i: number) => unknown) => kept(target).flatMap(fn as never);
|
||||
if (prop === "reduce") {
|
||||
return (fn: (acc: unknown, v: unknown, i: number) => unknown, init?: unknown) =>
|
||||
init === undefined
|
||||
? kept(target).reduce(fn as never)
|
||||
: kept(target).reduce(fn as never, init);
|
||||
}
|
||||
if (prop === "getById" || prop === "getBy") {
|
||||
const inner = Reflect.get(target, prop, target) as ((...a: unknown[]) => unknown) | undefined;
|
||||
if (typeof inner !== "function") return inner;
|
||||
return (...args: unknown[]) => {
|
||||
const item = inner.apply(target, args);
|
||||
return keep(item) ? item : undefined;
|
||||
};
|
||||
}
|
||||
|
||||
// MUTATIONS forward untouched. They take an item and return void or a boolean, so
|
||||
// they yield nothing to leak — and the view must not break writes or the underlying
|
||||
// reactivity. (Caught by `test/read-filter.test.ts` when the blanket refusal below
|
||||
// was first written: refusing everything unknown also refused `add`.)
|
||||
if (prop === "add" || prop === "delete" || prop === "clear") {
|
||||
const fn = Reflect.get(target, prop, target);
|
||||
return typeof fn === "function" ? fn.bind(target) : fn;
|
||||
}
|
||||
|
||||
// RAW ESCAPE HATCHES. `DeepSignalSet` exposes the underlying collection on
|
||||
// dunder keys (`__raw__`, `__meta__` — `RAW_KEY` in `@ng-org/alien-deepsignals`),
|
||||
// and the header used to claim "a plain property carries no items". It does here:
|
||||
// `view.__raw__` handed back the unfiltered Set, every identity's items in it.
|
||||
// Found by re-running the adversary on the fix (2026-08-07). Any dunder key is
|
||||
// refused, because that is the convention the escape hatches follow.
|
||||
if (typeof prop === "string" && prop.startsWith("__")) {
|
||||
throw new Error(
|
||||
`[ng-eventually] read filter: \`${prop}\` reaches past the view to the raw ` +
|
||||
"collection, which holds every identity's items. There is no filtered form of it.",
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
return answerUnnamedMember(target, prop, "the collection view");
|
||||
};
|
||||
|
||||
return new Proxy(set, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === Symbol.iterator) return function* () { yield* kept(target); };
|
||||
// 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]();
|
||||
if (prop === "entries") return () => kept(target).map((i) => [i, i] as const)[Symbol.iterator]();
|
||||
if (prop === "forEach") {
|
||||
return (cb: (v: unknown, v2: unknown, s: unknown) => void) => {
|
||||
for (const item of kept(target)) cb(item, item, receiver);
|
||||
};
|
||||
}
|
||||
// `has(item)` is filtered, not forwarded: the caller already holds the item, so the
|
||||
// answer discloses nothing new — but upstream an unreadable item is never delivered
|
||||
// at all, so "yes it is in there" would be an answer the target cannot give.
|
||||
if (prop === "has") return (item: unknown) => keep(item) && (target as Set<unknown>).has(item);
|
||||
// The reactive-set extras: they iterate, so they must iterate the filtered items.
|
||||
// The list is `iteratorHelperKeys` from `@ng-org/alien-deepsignals` — an earlier
|
||||
// pass whitelisted half of it and threw on the rest, so a holder's calls on their
|
||||
// OWN data crashed (`toArray`, `reduce`, `first`…). Filtering is the answer for all
|
||||
// of them; refusing is only for what is not on this list.
|
||||
if (prop === "map") return (fn: (v: unknown, i: number) => unknown) => kept(target).map(fn);
|
||||
if (prop === "filter") return (fn: (v: unknown, i: number) => boolean) => kept(target).filter(fn);
|
||||
if (prop === "find") return (fn: (v: unknown, i: number) => boolean) => kept(target).find(fn);
|
||||
if (prop === "some") return (fn: (v: unknown, i: number) => boolean) => kept(target).some(fn);
|
||||
if (prop === "every") return (fn: (v: unknown, i: number) => boolean) => kept(target).every(fn);
|
||||
if (prop === "toArray") return () => kept(target);
|
||||
if (prop === "first") return () => kept(target)[0];
|
||||
if (prop === "take") return (n: number) => kept(target).slice(0, n);
|
||||
if (prop === "drop") return (n: number) => kept(target).slice(n);
|
||||
if (prop === "flatMap") return (fn: (v: unknown, i: number) => unknown) => kept(target).flatMap(fn as never);
|
||||
if (prop === "reduce") {
|
||||
return (fn: (acc: unknown, v: unknown, i: number) => unknown, init?: unknown) =>
|
||||
init === undefined
|
||||
? kept(target).reduce(fn as never)
|
||||
: kept(target).reduce(fn as never, init);
|
||||
}
|
||||
if (prop === "getById" || prop === "getBy") {
|
||||
const inner = Reflect.get(target, prop, target) as ((...a: unknown[]) => unknown) | undefined;
|
||||
if (typeof inner !== "function") return inner;
|
||||
return (...args: unknown[]) => {
|
||||
const item = inner.apply(target, args);
|
||||
return keep(item) ? item : undefined;
|
||||
};
|
||||
}
|
||||
|
||||
// MUTATIONS forward untouched. They take an item and return void or a boolean, so
|
||||
// they yield nothing to leak — and the view must not break writes or the underlying
|
||||
// reactivity. (Caught by `test/read-filter.test.ts` when the blanket refusal below
|
||||
// was first written: refusing everything unknown also refused `add`.)
|
||||
if (prop === "add" || prop === "delete" || prop === "clear") {
|
||||
const fn = Reflect.get(target, prop, target);
|
||||
return typeof fn === "function" ? fn.bind(target) : fn;
|
||||
}
|
||||
|
||||
// RAW ESCAPE HATCHES. `DeepSignalSet` exposes the underlying collection on
|
||||
// dunder keys (`__raw__`, `__meta__` — `RAW_KEY` in `@ng-org/alien-deepsignals`),
|
||||
// and the header used to claim "a plain property carries no items". It does here:
|
||||
// `view.__raw__` handed back the unfiltered Set, every identity's items in it.
|
||||
// Found by re-running the adversary on the fix (2026-08-07). Any dunder key is
|
||||
// refused, because that is the convention the escape hatches follow.
|
||||
if (typeof prop === "string" && prop.startsWith("__")) {
|
||||
throw new Error(
|
||||
`[ng-eventually] read filter: \`${prop}\` reaches past the view to the raw ` +
|
||||
"collection, which holds every identity's items. There is no filtered form of it.",
|
||||
);
|
||||
}
|
||||
|
||||
// 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");
|
||||
get: (target, prop, receiver) => read(target, prop, receiver),
|
||||
getOwnPropertyDescriptor(target, prop) {
|
||||
// An index past what may be read names no position of this view, and saying so is
|
||||
// what keeps `has`, `ownKeys` and the descriptor telling one story.
|
||||
const i = indexOf(prop);
|
||||
if (i !== null && i >= kept(target).length) return undefined;
|
||||
return filteredDescriptor(target, prop, () => read(target, prop, target));
|
||||
},
|
||||
has(target, prop) {
|
||||
const i = indexOf(prop);
|
||||
return i === null ? Reflect.has(target, prop) : i < kept(target).length;
|
||||
},
|
||||
ownKeys(target) {
|
||||
// A Set holds its items internally and owns no key naming one, so there is nothing
|
||||
// to project: `Object.keys` on it is already empty (measured). An array owns one key
|
||||
// per position, and forwarding those published HOW MANY items the collection holds
|
||||
// while `length` said how many may be read - the two disagreed by the leak.
|
||||
if (!arrayLike) return Reflect.ownKeys(target);
|
||||
const keys: Array<string | symbol> = [];
|
||||
for (let i = 0; i < kept(target).length; i++) keys.push(String(i));
|
||||
// Everything that is not a position passes through, so a key the target cannot hide
|
||||
// - an array's own `length` - is never dropped.
|
||||
for (const k of Reflect.ownKeys(target)) if (indexOf(k) === null) keys.push(k);
|
||||
return keys;
|
||||
},
|
||||
}) as S;
|
||||
}
|
||||
@@ -311,40 +399,47 @@ function filteredPromise(value: PromiseLike<unknown>, caps: CapRegistry): Promis
|
||||
*/
|
||||
// @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 {
|
||||
/** What the view answers for one member. The `get` trap and the descriptor trap BOTH
|
||||
* call this, so neither can answer what the other refuses - `data` used to come back
|
||||
* RAW through `Object.getOwnPropertyDescriptor`, and so did the handle `get` throws on. */
|
||||
const read = (target: object, prop: string | symbol): unknown => {
|
||||
// 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");
|
||||
};
|
||||
|
||||
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");
|
||||
},
|
||||
get: (target, prop) => read(target, prop),
|
||||
getOwnPropertyDescriptor: (target, prop) =>
|
||||
filteredDescriptor(target, prop, () => read(target, prop)),
|
||||
}) as R;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* 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
|
||||
* page commands and the load state pass through. A member's DESCRIPTOR answers what
|
||||
* reading it answers, so the spread and the inspections built on that call read through
|
||||
* the view rather than round it. 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.
|
||||
*
|
||||
|
||||
@@ -142,3 +142,47 @@ test("forEach is filtered too", () => {
|
||||
makeReadFilteredView(set, caps).forEach((i) => seen.push((i as Item).id));
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
|
||||
// REGRESSION (third adversarial pass, 2026-09-24). `Object.getOwnPropertyDescriptor`
|
||||
// reads a property WITHOUT going through the `get` trap, so a view that filtered `get`
|
||||
// alone handed the raw value back to whoever asked for the descriptor - the dunder escape
|
||||
// hatch included, which `get` refuses by name. Everything built on that call goes the same
|
||||
// way: `Object.keys`, `Object.entries`, a spread, `JSON.stringify`, a console inspection.
|
||||
test("a descriptor cannot answer what reading the member refuses", () => {
|
||||
const set = new Set<Item>([MINE]) as any;
|
||||
set.__raw__ = set;
|
||||
const { caps, become } = setup("alice");
|
||||
const view = makeReadFilteredView(set, caps) as any;
|
||||
become("bob");
|
||||
expect(() => Object.getOwnPropertyDescriptor(view, "__raw__")).toThrow(/raw collection/i);
|
||||
});
|
||||
|
||||
// An `orderBy` config makes upstream's collection an ARRAY, and an array says how many
|
||||
// items it holds in four different places. `length` was filtered while `Object.keys`, `in`
|
||||
// and the descriptor of each index still answered from the collection underneath - so the
|
||||
// count of what a holder may NOT read came back out through the other three, and the
|
||||
// descriptor handed back the items themselves.
|
||||
test("the array form counts and addresses the readable items, by every route", () => {
|
||||
const items = [MINE, FOREIGN, LINKED];
|
||||
const { caps, become } = setup("alice");
|
||||
const view = makeReadFilteredView(items, caps) as any;
|
||||
become("bob"); // holds nothing of the three documents
|
||||
|
||||
expect(view.length).toBe(0);
|
||||
expect(Object.keys(view)).toEqual([]);
|
||||
expect(0 in view).toBe(false);
|
||||
expect(view[0]).toBeUndefined();
|
||||
expect(Object.getOwnPropertyDescriptor(view, "0")).toBeUndefined();
|
||||
expect(JSON.parse(JSON.stringify(view))).toEqual([]);
|
||||
|
||||
become("alice"); // holds her own document and the one she published
|
||||
expect(view.length).toBe(2);
|
||||
expect(Object.keys(view)).toEqual(["0", "1"]);
|
||||
expect(view[0].id).toBe("a");
|
||||
expect(view[1].id).toBe("p");
|
||||
expect(Object.getOwnPropertyDescriptor(view, "1")?.value.id).toBe("p");
|
||||
expect(2 in view).toBe(false);
|
||||
// `length` is the array's own key and is never hidden - a proxy may not drop a key the
|
||||
// target cannot configure away - but it is not a position and does not enumerate.
|
||||
expect(Object.getOwnPropertyNames(view)).toContain("length");
|
||||
});
|
||||
|
||||
@@ -262,7 +262,36 @@ test("useShape: an ordered result does not publish the unfiltered count", async
|
||||
expect((hook.raw() as BaseType[]).length).toBe(3);
|
||||
expect((data as { length: number }).length).toBe(1);
|
||||
expect([...(data as Iterable<BaseType>)].map((o) => o["@graph"])).toEqual([hers]);
|
||||
// An index hands back a raw item and there is no projection of it this file would not
|
||||
// have to invent, so it is refused rather than guessed at.
|
||||
expect(() => (data as Record<number, unknown>)[0]).toThrow(/not filtered/i);
|
||||
// An index names a position in the READABLE items, so the holder reads their own by
|
||||
// index and the positions past them do not exist at all. `Object.keys` and `in` have to
|
||||
// agree with `length`, or the count comes back out through the other three.
|
||||
expect((data as Record<number, BaseType>)[0]!["@graph"]).toBe(hers);
|
||||
expect((data as Record<number, BaseType>)[1]).toBeUndefined();
|
||||
expect(Object.keys(data as object)).toEqual(["0"]);
|
||||
expect(1 in (data as object)).toBe(false);
|
||||
expect(JSON.parse(JSON.stringify(data)) as BaseType[]).toHaveLength(1);
|
||||
});
|
||||
|
||||
// REGRESSION, found by re-running the adversary on the first fix (2026-09-24).
|
||||
// `Object.getOwnPropertyDescriptor` reads a property WITHOUT the `get` trap, so a view
|
||||
// that filtered `get` alone handed the raw value straight back to whoever asked for the
|
||||
// descriptor - including `subscription`, the one member `get` refuses outright. Everything
|
||||
// built on it goes the same way: a spread, `Object.entries`, a console inspection.
|
||||
test("useShape: the descriptor of a member answers exactly what reading it answers", async () => {
|
||||
const hook = makeUseShapeHook();
|
||||
inject(hook);
|
||||
const hers = await documentOf("alice");
|
||||
const his = await documentOf("bob");
|
||||
hook.items.push(noteIn(hers, "a1"), noteIn(his, "b1"));
|
||||
|
||||
setCurrentUser("bob");
|
||||
const result: ShapeResult = useShape(NoteShape, EVERYTHING);
|
||||
|
||||
const described = Object.getOwnPropertyDescriptor(result, "data");
|
||||
expect(described?.value).toBe(result.data);
|
||||
expect(described?.value).not.toBe(hook.raw());
|
||||
expect([...(described?.value as Iterable<BaseType>)].map((o) => o["@graph"])).toEqual([his]);
|
||||
expect(() => Object.getOwnPropertyDescriptor(result, "subscription")).toThrow(
|
||||
/RdfOrmSubscription/,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user