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:
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user