loopable / web /src /settings /permsModel.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
609fb78 verified
Raw
History Blame Contribute Delete
28 kB
// ---------------------------------------------------------------------------
// settings / permsModel.ts β€” wave 15, contract C-PERM (ruling R9).
//
// The permission editor's PURE HALF: the wire shapes, the parse, the draft the
// admin is editing, and the body the PUT sends. No React, no fetch, so the
// rules that decide who can see what are testable under node without a browser
// or a server (verify_login's `_test` harness runs them).
//
// β›” NOTHING HERE ENFORCES ANYTHING. Every rule below is about producing a
// WELL-FORMED and HONEST payload; the wall is `core/perm_scope.permits()` on the
// server, and it re-validates all of this. An editor that produced a malformed
// record would be refused β€” the point of the care here is that it never gets
// that far, and that the admin is never shown a state the server does not hold.
//
// THREE RULES THAT LOOK LIKE STYLE AND ARE NOT:
//
// 1. **A module with no readable schema renders as an ACCESS TOGGLE ONLY, and
// saves `{access, filter: null, hiddenFields: []}`** (R9). The failure mode
// it prevents: half a filter tree, PUT against a field list nobody could
// read. Fail-closed is not "deny everything" here β€” it is "never write a
// restriction you could not show the admin".
// 2. **An unknown field TYPE lands on `text`**, it does not disappear and it
// is not passed through. Passing it through would hand the condition
// builder an operator set for a type it does not have; dropping the field
// would take away an admin's ability to HIDE a column just because they
// cannot filter on it. `text` is the house fallback the viz layer already
// uses for exactly this (verify_ui's `field-vocabulary-not-whitelisted`).
// 3. **A module the server no longer declares is DROPPED by the whole-record
// replace β€” and the editor says so out loud** (`orphanModules`). A silent
// drop of somebody's permanent filter is a permission change nobody made.
// ---------------------------------------------------------------------------
import type { Field, FieldType, FilterTree } from "../customer-grid/types";
// --- who may reach the editor at all ----------------------------------------
/** The modal's rail entries. Declared HERE, beside the rule that gates them, so
* the component and the gate read one definition of the vocabulary rather than
* two that can drift by one member. `SettingsModal` re-exports it. */
export type SettingsSection =
| "account"
| "scope"
| "users"
// Wave 18 (C7): the tenant's credential store and its data-source status board.
| "keychains"
| "connectors"
/** EXIT-6: the statement-of-account sender, ported off `app.py` when Streamlit
* was deleted. Admin-only for the same reason the Streamlit section was β€”
* it is THE one sanctioned Odoo writer in the product. */
| "statements"
/**
* Wave 19 (R3 / contract C2): the Loopable admin plane β€” the cross-TENANT
* console, visible only to a `platform_admin` account.
*
* β›” DELIBERATELY ABSENT FROM `reachableSection` BELOW, and that is not an
* oversight. Every other admin room is gated on `admin`, a boolean the shell
* already holds on the user record. `platform_admin` is a different predicate
* that arrives ASYNCHRONOUSLY on the settings payload β€” and R3 is explicit
* that a tenant-scoped `is_admin` does NOT qualify for it. Folding it into a
* function whose only input is `admin` would either grant the section to every
* tenant admin or deny it to the one account that has it. It is gated where
* the flag actually lives, at the render site in `SettingsModal`.
*/
| "padmin";
/**
* A non-admin never LANDS on the users pane β€” and therefore never reaches the
* permission editor, which lives inside it.
*
* β›” THIS IS A COURTESY, NOT THE CHECK. The server refuses every `/admin/*`
* route to a member regardless, and `/perms` is one of them. What this prevents
* is a member deep-linked (or restored) into a room whose every control would
* 403 β€” an empty frame that reads as a broken product rather than as a closed
* door. It is a named function instead of an inline ternary for one reason: an
* inline ternary cannot have a negative control, and "the pane a non-admin gets
* bounced out of" is exactly the kind of rule that gets refactored away by
* someone who does not know it is load-bearing.
*/
export function reachableSection(section: SettingsSection, admin: boolean): SettingsSection {
// ⭐⭐ WAVE 32 Β· R4 β€” `keychains` AND `connectors` LEFT THIS LIST, and the ruling is the reason.
//
// Wave 18 (C7) put them here because "every control inside them would 403 a member" β€” which was
// TRUE while every credential was tenant-wide. R4 ends that: *"the business-wide vs personal
// split lands on ALL connections … business-wide is admin-only"*, i.e. a member now genuinely
// owns something in these rooms β€” their own personal connections.
//
// β›” THE SERVER MOVED FIRST, and this line follows it rather than leading. `routes_keychain`
// dropped `admin_gate` for `require_session` and put the wall in the ROW (`may_see` /
// `_may_touch`): a member sees the business-wide entries plus their own, may create only a
// personal one, and gets `403 not_admin` with a sentence if they ask for business-wide. Had
// this list changed alone, a member would land in a room whose every control 403s β€” the empty
// frame this function's own header calls "a broken product rather than a closed door".
const adminOnly =
section === "users" ||
// EXIT-6: every control in the statements room would 403 a member, and the
// one at the bottom sends customer email. It belongs in this list twice over.
section === "statements";
return adminOnly && !admin ? "account" : section;
}
// --- the wire (C-PERM) ------------------------------------------------------
/** One module's rule for one user. The shape `PUT /admin/users/{u}/perms` takes. */
export interface PermsEntry {
/** May this account open the module at all. `false` β‡’ `may_open` denies. */
access: boolean;
/** The PERMANENT filter, AND-ed under everything the user does. `null` = none.
* ⚠ The whole tree β€” `{conj?, nodes}` β€” never a bare node list: `[A, B]` under
* `or` means something entirely different from `[A, B]` under `and`, and the
* loss is invisible in every payload (C-PERM amendment 2). */
filter: FilterTree | null;
/** Field keys this account never receives. Server-stripped from every wire. */
hiddenFields: string[];
}
export type PermsRecord = Record<string, PermsEntry>;
/** A module the editor can draw a full rule for: it came with a field list. */
export interface PermsModule {
key: string;
label: string;
/** Empty β‡’ no readable schema β‡’ access toggle only (rule 1 above). */
fields: Field[];
}
export interface PermsPayload {
/** In server order β€” the order the sections render in. */
modules: PermsModule[];
entries: PermsRecord;
/** Keys in `perms` that the server no longer declares. A whole-record replace
* drops them; the editor states that before the admin saves. */
orphanModules: string[];
/** C-PERM amendment 4's migration marker. ABSENT means this record still runs
* under the LEGACY wall (`bus`/`agent` query scope + the `modules` grant), so
* the editor must not present empty perms as "no access" β€” that would be a
* confident lie about an account that can currently see everything. */
migrated: boolean;
/** This account is an ADMIN, and admins bypass `perms` entirely. Every rule
* the editor can draw for them is inert β€” which it has to say out loud. */
isAdmin: boolean;
}
// --- parsing ----------------------------------------------------------------
/** Every `FieldType`, as a runtime set.
*
* ⚠ The `Record<FieldType, true>` is the point, not the Set: it is a
* COMPILE-TIME exhaustiveness check. Add a type to the union in
* `customer-grid/types.ts` and this file stops compiling until it is listed
* here β€” which is how a whitelist stays honest across a tree boundary S3 does
* not own. A hand-maintained array would silently narrow instead. */
const FIELD_TYPE_TABLE: Record<FieldType, true> = {
text: true, status: true, currency: true, int: true, date: true, pct: true,
select: true, user: true, multiselect: true, checkbox: true, phone: true,
email: true, url: true, rating: true, created_time: true, formula: true,
automation: true,
// Wave-22 C7 (added by C, the same one-key edit the alarm demands).
metric: true,
// Wave-19 R7 (added by session A β€” see the dated amendment in the split doc). This ONE key is
// the whole edit: the exhaustiveness alarm above did exactly what it promises, and the fix it
// names is a listing here. Nowhere near `SettingsSection` / the rail, which is B's half of
// this file.
image: true,
// Wave-23 C7 (added by session D β€” `settings/**` is frozen this wave and this ONE key is the
// exception the freeze cannot cover: the alarm four lines up is a COMPILE error, so the union
// and this listing cannot land in two different changes. Posted in D's mailbox for C.)
json: true,
// 2026-08-07 β€” the relational pair, listed for the reason the alarm above states and for no
// other: the exhaustiveness check is a COMPILE error, so the union and this listing cannot
// land in two separate changes. Nothing about the permissions wall treats either kind
// specially β€” a link/rollup column is granted and hidden like any other column.
link: true, rollup: true,
// ⭐ Wave-27 item 13 (R13) β€” same one-key edit, same reason, and `settings/**` is D's fence
// this wave so it is not even an exception: the alarm above is a COMPILE error, so the union
// and this listing cannot land in two changes. Nothing about the permissions wall treats a
// code column specially β€” it is granted and hidden like any other column.
code: true,
};
export const KNOWN_FIELD_TYPES: ReadonlySet<string> = new Set(Object.keys(FIELD_TYPE_TABLE));
/** Rule 2: whitelist, never pass through, never drop. */
export function fieldType(raw: unknown): FieldType {
return typeof raw === "string" && KNOWN_FIELD_TYPES.has(raw) ? (raw as FieldType) : "text";
}
function asStringArray(raw: unknown): string[] {
return Array.isArray(raw) ? raw.filter((v) => typeof v === "string") : [];
}
/** One field of a module's schema, from the nav/schema payload shape.
* `key` and `label` are the minimum that makes a row renderable β€” a field
* without them cannot be shown OR named in a rule, so it is skipped rather
* than rendered as a blank the admin might tick. */
export function parseField(raw: unknown): Field | null {
if (!raw || typeof raw !== "object") return null;
const r = raw as Record<string, unknown>;
if (typeof r.key !== "string" || r.key === "") return null;
const label = typeof r.label === "string" && r.label !== "" ? r.label : r.key;
const options = asStringArray(r.options);
return {
key: r.key,
label,
type: fieldType(r.type),
// Anything not explicitly the overlay stratum is treated as source data.
// Consequence in this editor: nothing here offers to EDIT a field, so the
// only thing `source` drives is the "Pre-set" chip in the hide list.
source: r.source === "overlay" ? "overlay" : "odoo",
// Carried because `identityKey` reads it β€” see the rule there.
...(r.pinned === true ? { pinned: true } : {}),
...(options.length ? { options } : {}),
...(typeof r.note === "string" ? { note: r.note } : {}),
...(typeof r.description === "string" && r.description !== ""
? { note: r.description }
: {}),
};
}
/** `fields_by_module[key]` is a BARE ARRAY of fields β€” S1's canonical answer
* (routes_admin `get_perms`), not the nav/schema envelope the contract line
* implied. The earlier tolerant branch that also read `{fields: […]}` is gone:
* one shape, asserted here, beats two readings of a sentence.
*
* The LABEL does not come from here β€” it rides the payload's `modules` list,
* which is also what fixes the ORDER. Anything unreadable yields an EMPTY field
* list, which R9 already has a defined rendering for (access toggle only). */
export function parseModule(key: string, label: string, raw: unknown): PermsModule {
const fields: Field[] = [];
for (const f of Array.isArray(raw) ? raw : []) {
const parsed = parseField(f);
if (parsed) fields.push(parsed);
}
return { key, label: label || key, fields };
}
/** A filter is taken WHOLE or not at all. A tree whose `nodes` is not a list is
* not a narrower filter, it is an unreadable one β€” and this editor's job is to
* never show a rule it could not also save back. */
export function parseFilter(raw: unknown): FilterTree | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const r = raw as Record<string, unknown>;
if (!Array.isArray(r.nodes)) return null;
const conj = r.conj === "or" ? "or" : r.conj === "and" ? "and" : undefined;
return {
...(conj ? { conj } : {}),
nodes: r.nodes as FilterTree["nodes"],
};
}
/** ⚠ ABSENT ENTRY β‡’ `access: false`, matching what the server will ENFORCE for a
* migrated record (C-PERM amendment 4: `perms_v == 1` + no entry β‡’ DENY). The
* editor showing "access on" for a module the wall denies would send an admin
* to debug a permission that was never granted.
*
* For an UN-migrated record the same absence means the opposite β€” the legacy
* wall still applies and may grant everything β€” which is why `PermsPayload`
* carries `migrated` and the editor states it rather than letting this default
* speak for a case it does not describe. */
export function parseEntry(raw: unknown): PermsEntry {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return { access: false, filter: null, hiddenFields: [] };
}
const r = raw as Record<string, unknown>;
return {
access: r.access === true,
filter: parseFilter(r.filter),
hiddenFields: normalizeHidden(asStringArray(r.hiddenFields)),
};
}
/** Sorted + de-duplicated, always. Dirty-detection compares payloads, so a list
* whose ORDER can drift would make an untouched module read as edited. */
export function normalizeHidden(keys: readonly string[]): string[] {
return [...new Set(keys)].sort();
}
/**
* The IDENTITY column β€” the one field a module may never hide.
*
* β›” WHY THIS EXISTS AT ALL. `FieldsHidePanel` takes an optional `lockedKey` and
* documents that "absent = nothing is locked". Absent, a single click on
* "Hide all" hides EVERY field including the row's own name β€” and with
* amendment 5's server-side transitive closure behind it, that writes a record
* whose faithful enforcement is a table of blank rows. The user could open the
* module and see nothing in it, the wall doing exactly what the record said.
* Nothing in C-PERM's PUT validation refuses that: it refuses UNKNOWN field
* keys, and the identity column is perfectly known.
*
* ⚠ MIRRORED, NOT INVENTED: this is `useGridColumns.ts:201-204`'s rule verbatim
* β€” the pinned field, else the first. The grid computes the same key to decide
* which column its own Hide-fields panel locks, so an editor using a different
* rule would lock a different column from the one the table protects.
*/
export function identityKey(fields: readonly Field[]): string {
return fields.find((f) => f.pinned)?.key ?? fields[0]?.key ?? "";
}
/** Every field a module MAY hide β€” the whole list minus its identity column. */
export function hideableKeys(fields: readonly Field[]): string[] {
const locked = identityKey(fields);
return fields.filter((f) => f.key !== locked).map((f) => f.key);
}
/** `{perms, fields_by_module}` β†’ everything the editor renders. Returns `null`
* only for a body that is not an object at all; a body missing either half
* parses to an editor with nothing to offer, which is the honest rendering of
* "the server told us nothing". */
export function parsePermsPayload(body: unknown): PermsPayload | null {
if (!body || typeof body !== "object") return null;
const b = body as Record<string, unknown>;
const byModule =
b.fields_by_module && typeof b.fields_by_module === "object"
? (b.fields_by_module as Record<string, unknown>)
: {};
const rawPerms =
b.perms && typeof b.perms === "object" && !Array.isArray(b.perms)
? (b.perms as Record<string, unknown>)
: {};
// ⚠ `modules` CARRIES THE LABELS AND THE ORDER, and `fields_by_module` carries
// neither. Deriving sections from the field map alone renders every heading as
// a registry KEY β€” "customer_data" where the product says "Customer" β€” and in
// whatever order the JSON happens to enumerate. The field map is still the
// outer bound: a module named in `modules` with no field list is the
// schema-less case, not an error.
const declared: Array<{ key: string; label: string }> = Array.isArray(b.modules)
? (b.modules as unknown[]).flatMap((m) => {
if (!m || typeof m !== "object") return [];
const r = m as Record<string, unknown>;
return typeof r.key === "string" && r.key
? [{ key: r.key, label: typeof r.label === "string" ? r.label : r.key }]
: [];
})
: Object.keys(byModule).map((k) => ({ key: k, label: k }));
const modules = declared.map((d) => parseModule(d.key, d.label, byModule[d.key]));
const entries: PermsRecord = {};
for (const m of modules) entries[m.key] = parseEntry(rawPerms[m.key]);
return {
modules,
entries,
orphanModules: Object.keys(rawPerms).filter((k) => !(k in entries)).sort(),
migrated: typeof b.perms_v === "number" && b.perms_v >= 1,
// `role == 'admin'` bypasses `perms` entirely (C-PERM amendment 4). Sent by
// the route so the editor can SAY so instead of rendering stored rules that
// do not apply β€” the one misreading of that clause that could hurt.
isAdmin: b.is_admin === true,
};
}
// --- the draft the admin is editing -----------------------------------------
/** Immutable edits: every setter returns a NEW record, so React sees the change
* and `isDirty` compares against a snapshot that no setter has mutated under
* it. In-place edits are how a Save button ends up permanently greyed. */
function withEntry(rec: PermsRecord, key: string, patch: Partial<PermsEntry>): PermsRecord {
const cur = rec[key] ?? { access: false, filter: null, hiddenFields: [] };
return { ...rec, [key]: { ...cur, ...patch } };
}
export function setAccess(rec: PermsRecord, key: string, access: boolean): PermsRecord {
return withEntry(rec, key, { access });
}
export function setFilter(rec: PermsRecord, key: string, filter: FilterTree | null): PermsRecord {
// An empty tree is NO filter, not an empty one. `{nodes: []}` matches every
// row, so persisting it would mean "restricted, to everything" β€” a rule that
// reads as a restriction in the record and is not one on screen.
const empty = !filter || filter.nodes.length === 0;
return withEntry(rec, key, { filter: empty ? null : filter });
}
export function setHidden(rec: PermsRecord, key: string, hidden: readonly string[]): PermsRecord {
return withEntry(rec, key, { hiddenFields: normalizeHidden(hidden) });
}
export function toggleHidden(rec: PermsRecord, key: string, fieldKey: string): PermsRecord {
const cur = rec[key]?.hiddenFields ?? [];
const next = cur.includes(fieldKey)
? cur.filter((k) => k !== fieldKey)
: [...cur, fieldKey];
return setHidden(rec, key, next);
}
export function hiddenSet(rec: PermsRecord, key: string): ReadonlySet<string> {
return new Set(rec[key]?.hiddenFields ?? []);
}
export function filterOf(rec: PermsRecord, key: string): FilterTree {
// The panel takes a tree, never null β€” an absent filter is an EMPTY tree to
// edit, which is what "add your first condition" has to render against.
return rec[key]?.filter ?? { nodes: [] };
}
// --- what gets sent ---------------------------------------------------------
/**
* The PUT body. Whole-record replace, so this emits an entry for EVERY module
* the server declared β€” including the ones the admin never touched, because a
* missing key in a replace is a deletion, not a no-op.
*
* ⚠ A module with no readable schema is emitted `{access, filter: null,
* hiddenFields: []}` (R9). Its access toggle is real and is honoured; what is
* refused is inventing a restriction against a field list nobody could read.
*/
export function toPutBody(payload: PermsPayload, rec: PermsRecord): { perms: PermsRecord } {
const perms: PermsRecord = {};
for (const m of payload.modules) {
const e = rec[m.key] ?? { access: false, filter: null, hiddenFields: [] };
// ⚠ The identity column is stripped at the BOUNDARY as well as withheld
// from the UI. The panel never offers it, but a record written before this
// rule β€” or by anything else β€” could still name it, and this editor is the
// last place that record passes through before the wall enforces it
// faithfully. Two layers for the same reason C-PERM validates at PUT time
// AND `permits()` re-checks: records go stale in ways forms cannot.
const locked = identityKey(m.fields);
perms[m.key] = m.fields.length
? {
access: e.access,
filter: e.filter,
hiddenFields: normalizeHidden(e.hiddenFields.filter((k) => k !== locked)),
}
: { access: e.access, filter: null, hiddenFields: [] };
}
return { perms };
}
/** Which slice of one account's access a copy carries onto another. */
export type CopyScope = { kind: "all" } | { kind: "module"; key: string };
/**
* C-PERMCOPY (wave 17, item 16) β€” the record to PUT onto ONE target, composed
* from that target's OWN payload and this account's SAVED entries.
*
* β›” WHY THIS IS A FUNCTION AND NOT FOUR LINES IN THE COMPONENT. It is the
* SECOND producer of a PUT body in the product, and the first one aimed at
* somebody else's record. A PUT is a whole-record replace: a key that does not
* come back is DELETED. `toPutBody` already carries a negative control named
* `replace-omits-untouched-modules` for exactly that failure β€” and every way of
* getting it wrong from here reaches the same place by a different road. Inline
* in a `useCallback`, no gate could see any of it.
*
* THE RULE, in one line: **the target's tree is the base; the copy overwrites
* a slice of it.** Never the source's tree with the target's bits merged in β€”
* that composes a record out of modules the TARGET may not declare.
*
* Β· `{kind:'all'}` β€” the whole record is replaced by the source's.
* Β· `{kind:'module'}` β€” that one module is replaced; every other module keeps
* the value the target's own GET returned, byte for byte.
*
* ⚠ TWO EDGES, both currently unreachable because `_PERM_MODULES` is server-wide
* (every payload declares the same modules), and both named rather than left to
* be discovered if that ever stops being true:
* 1. The source has a module the TARGET does not declare. `toPutBody` iterates
* the TARGET's modules, so the copy is dropped β€” silently, under a message
* that says it was copied. `copyDropped()` below is what lets the caller
* tell the truth about that.
* 2. The target declares a module the SOURCE has no entry for. It lands on
* `NO_ACCESS`, which is the honest reading of "apply this account's access":
* if the source does not grant it, the target must not keep it. Deliberate,
* and it is why a whole-record copy is offered as "replace", not "merge".
*/
export function copyTargetRecord(
target: PermsPayload,
source: PermsRecord,
scope: CopyScope
): PermsRecord {
if (scope.kind === "all") {
const out: PermsRecord = {};
for (const m of target.modules) {
out[m.key] = source[m.key] ?? { access: false, filter: null, hiddenFields: [] };
}
return out;
}
return {
...target.entries,
[scope.key]: source[scope.key] ?? { access: false, filter: null, hiddenFields: [] },
};
}
/** The modules a copy CANNOT carry, because the target does not declare them.
* Empty in every deployment where the module list is server-wide; a caller that
* reports "copied" without consulting it would be guessing. */
export function copyDropped(
target: PermsPayload,
source: PermsRecord,
scope: CopyScope
): string[] {
const declared = new Set(target.modules.map((m) => m.key));
const wanted = scope.kind === "all" ? Object.keys(source) : [scope.key];
return wanted.filter((k) => !declared.has(k)).sort();
}
/** Compares the SENT SHAPE, not the draft, so a change the payload cannot carry
* (a hidden-field list on a schema-less module) never lights up Save. */
export function isDirty(payload: PermsPayload, saved: PermsRecord, draft: PermsRecord): boolean {
return (
JSON.stringify(toPutBody(payload, saved)) !== JSON.stringify(toPutBody(payload, draft))
);
}
// --- how a rule reads -------------------------------------------------------
/** Counts a tree's leaves, groups included. The editor states the size of a
* restriction rather than showing "Filtered" for one condition and for twenty. */
export function countLeaves(tree: FilterTree | null): number {
if (!tree) return 0;
let n = 0;
const walk = (nodes: readonly unknown[]) => {
for (const node of nodes) {
if (node && typeof node === "object" && Array.isArray((node as { children?: unknown[] }).children)) {
walk((node as { children: unknown[] }).children);
} else {
n += 1;
}
}
};
walk(tree.nodes);
return n;
}
/**
* One sentence per module for the accounts list and the section head.
*
* ⚠ NO EMOJI, no icon vocabulary β€” this string is read aloud by a screen reader
* and printed in the gate's output. It also never says "restricted" without
* saying to WHAT: "Filtered" alone is the kind of summary that makes an admin
* open every section to find the one that is set.
*/
export function moduleSummary(entry: PermsEntry | undefined, schemaless = false): string {
if (!entry || !entry.access) return "No access";
if (schemaless) return "Full access";
const conds = countLeaves(entry.filter);
const hidden = entry.hiddenFields.length;
if (!conds && !hidden) return "Full access";
const parts: string[] = [];
if (conds) parts.push(`${conds} condition${conds === 1 ? "" : "s"}`);
if (hidden) parts.push(`${hidden} field${hidden === 1 ? "" : "s"} hidden`);
return parts.join(", ");
}
/** The account-list cell: what this user may open, across all modules. */
export function accessSummary(payload: PermsPayload | null, rec: PermsRecord): string {
if (!payload || payload.modules.length === 0) return "";
const open = payload.modules.filter((m) => rec[m.key]?.access);
if (open.length === 0) return "No access";
// ⚠ A SCHEMA-LESS MODULE CANNOT BE RESTRICTED, so it is never counted as one
// β€” the same guard `moduleSummary` takes. Without it a draft filter on a
// module whose rule `toPutBody` strips would be summarised as "1 restricted",
// describing a restriction that is not going to be saved. The header and the
// Save confirmation both read this sentence, so it must describe the PAYLOAD.
const restricted = open.filter(
(m) =>
m.fields.length > 0 &&
((rec[m.key]?.filter?.nodes.length ?? 0) > 0 ||
(rec[m.key]?.hiddenFields.length ?? 0) > 0)
).length;
const all = open.length === payload.modules.length;
const head = all
? `All ${open.length} module${open.length === 1 ? "" : "s"}`
: `${open.length} of ${payload.modules.length} modules`;
return restricted ? `${head}, ${restricted} restricted` : head;
}