Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/23539-references-permission-fields-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/metadata-protocol': patch
---

fix(metadata-protocol): an object's "Used by" answer lists a permission set that names the object only in its `fields` keys (#23539)

**What was wrong.** `findReferencesToMeta` serves `GET /api/v1/meta/object/:name/references`, the admin "Used by" panel, which reads an empty answer as "Safe to delete." `PermissionSetSchema.fields` is keyed `<object>.<field>`, and the security plugin applies a set's field rules to an object by that key prefix. The reference index read `permission.fields` only as a site naming `field` items, never an object. So a set whose only rule for an object was a field rule (`objects: {}`, `fields: { 'customer.full_name': … }`) was missing from that object's answer, and the object could be cleared for deletion while the set still masked its fields.

**What the answer reads now.** Such a set is listed for the object that each `fields` key starts with, at path `fields{key}` with kind `permission fields`. The object part is the text before the key's first `.`, the same split the security plugin makes. A key whose field part spells another object's name is not a reference to that object. A key with no object part (`.full_name`) names no object.

Unchanged: every existing row of the answer, the response shape, the `field` target's `501 NOT_IMPLEMENTED` refusal, and `duplicatePackage`.
77 changes: 73 additions & 4 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5379,6 +5379,48 @@ function collectReferenceHits(
return hits;
}

/**
* Every place a stored document names `targetName` as the HEAD of a composite
* record key, through a composite-key site of the derived index
* (`REFERENCE_SITES.compositeKeyByTarget`; the one site today is a permission
* set's `fields`, keyed `<object>.<field>`).
*
* A separate walk, not a branch inside {@link collectReferenceHits}: that walk
* matches a WHOLE value and serves every other site, so it stays as it is. This
* one reads only the record each site names, at the document ROOT where the
* site's premise is derived (`compositeKeySiteHolds`, `./reference-sites.ts`),
* and only its keys.
*
* A key names the target when the text before its FIRST separator equals the
* target's name — the split the security plugin makes for field rules
* (`key.startsWith(object + '.')`), exact because no object name contains a `.`.
* A key with no separator, or one at index 0 (`.full_name`), names no object.
* The field part is never read, so a key whose FIELD part spells the target
* (`invoice.customer`) is not a reference to it. One hit per site: the reported
* path (`fields{key}`) does not carry the key, so a second matching key is the
* same row.
*/
function collectCompositeKeyHeadHits(
item: unknown,
separators: ReadonlyMap<string, string>,
targetName: string,
): Array<{ path: string; property: string }> {
const hits: Array<{ path: string; property: string }> = [];
if (!item || typeof item !== 'object' || Array.isArray(item)) return hits;
for (const [property, separator] of separators) {
const record = (item as Record<string, unknown>)[property];
if (!record || typeof record !== 'object' || Array.isArray(record)) continue;
for (const key of Object.keys(record)) {
const at = key.indexOf(separator);
if (at > 0 && key.slice(0, at) === targetName) {
hits.push({ path: `${property}{key}`, property });
break;
}
}
}
return hits;
}

/**
* Phase 3a-destructive: detect changes between an existing object schema
* and an incoming overlay that would break runtime data — removed fields,
Expand Down Expand Up @@ -30227,8 +30269,17 @@ export class ObjectStackProtocolImplementation implements
throw err;
}

const sites = REFERENCE_SITES.byTarget.get(singularTarget);
if (!sites || sites.length === 0) {
const sites = REFERENCE_SITES.byTarget.get(singularTarget) ?? [];
// [#23539] Sites that name the target in the HEAD of a composite record
// key — a permission set's `fields`, keyed `<object>.<field>`, for target
// `object` and nothing else. They live in their own map, never in
// `byTarget`, because `duplicatePackage` reads `byTarget.get('object')` as
// a flat set of object-naming property names (see
// COMPOSITE_KEY_REFERENCE_SITES in `./reference-sites.ts`). Without them a
// set whose only rule for an object was a field rule was missing here,
// and the "Used by" panel could clear that object for deletion.
const compositeSites = REFERENCE_SITES.compositeKeyByTarget.get(singularTarget) ?? [];
if (sites.length === 0 && compositeSites.length === 0) {
return { references: [] };
}

Expand All @@ -30244,7 +30295,23 @@ export class ObjectStackProtocolImplementation implements
}
properties.set(site.property, site.target);
}
const matchers = [...propertiesByFromType].map(([fromType, properties]) => ({ fromType, properties }));
// Composite-key sites share that one read: property → separator, per
// source type, and a source type they alone name still gets its matcher.
const separatorsByFromType = new Map<string, Map<string, string>>();
for (const site of compositeSites) {
let separators = separatorsByFromType.get(site.fromType);
if (!separators) {
separators = new Map<string, string>();
separatorsByFromType.set(site.fromType, separators);
}
separators.set(site.property, site.separator);
if (!propertiesByFromType.has(site.fromType)) propertiesByFromType.set(site.fromType, new Map());
}
const matchers = [...propertiesByFromType].map(([fromType, properties]) => ({
fromType,
properties,
separators: separatorsByFromType.get(fromType),
}));

const seen = new Set<string>(); // dedup key: `${fromType}|${itemName}|${path}`
const out: Array<{ type: string; name: string; label?: string; path: string; kind: string }> = [];
Expand Down Expand Up @@ -30291,7 +30358,9 @@ export class ObjectStackProtocolImplementation implements
// Don't list an item as a reference to itself unless the
// self-reference is meaningful (e.g. object→field path).
const isSelfReference = matcher.fromType === singularTarget && sourceName === targetName;
for (const { path, property } of collectReferenceHits(raw, matcher.properties, targetName)) {
const hits = collectReferenceHits(raw, matcher.properties, targetName);
if (matcher.separators) hits.push(...collectCompositeKeyHeadHits(raw, matcher.separators, targetName));
for (const { path, property } of hits) {
// An item naming ITSELF through a scalar property is
// the item, not a dependent. Collection-valued paths
// are kept, because an item that lists itself among
Expand Down
26 changes: 26 additions & 0 deletions packages/metadata-protocol/src/reference-sites.derivation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,31 @@ describe('[#9327] the TARGET-side gap is named too, and it is a sibling not a wi
});
});

describe('[#23539] a permission set\'s `fields` keys name their object through a composite-key site', () => {
it('THE PIN: exactly one composite-key site, and it is permission.fields → object on "."', () => {
// `PermissionSetSchema.fields` is keyed `<object>.<field>`, a fact stated
// in prose only, so it is a seed — and its premises are derived: it
// drops out (turning this red) if `permission` stops declaring `fields`
// as a root record, or if `ObjectSchema.name` ever admits a ".".
// ⚠️ GROWS → a second hand-written key form; argue it, do not append it.
const all = [...REFERENCE_SITES.compositeKeyByTarget.entries()].flatMap(([target, sites]) =>
sites.map((s) => ({ target, fromType: s.fromType, property: s.property, separator: s.separator })));
expect(all).toEqual([{ target: 'object', fromType: 'permission', property: 'fields', separator: '.' }]);
});

it('it is NOT a byTarget row — `duplicatePackage` reads byTarget("object") as object-naming property names', () => {
// Measured with the row placed there: every copied object's own
// `fields` record (keyed by bare field name) had a key renamed after a
// renamed object. Its absence here is that copy's guard.
expect(hasSite('object', 'permission', 'fields')).toBe(false);
});

it('the `field` target is unmoved: permission.fields still names field items, and `field` still refuses', () => {
expect(hasSite('field', 'permission', 'fields')).toBe(true);
expect(REFERENCE_SITES.unanswerableTargetTypes).toEqual(['field']);
});
});

describe('[#9190] derived reference sites — derivation is a pure function of the schemas', () => {
it('two derivations of the same schemas agree exactly', () => {
// The module-load singleton must not be doing anything a caller cannot
Expand All @@ -209,6 +234,7 @@ describe('[#9190] derived reference sites — derivation is a pure function of t
.sort();
expect(flatten(a)).toEqual(flatten(b));
expect(flatten(a)).toEqual(flatten(REFERENCE_SITES));
expect([...a.compositeKeyByTarget]).toEqual([...REFERENCE_SITES.compositeKeyByTarget]);
});
});

Expand Down
148 changes: 140 additions & 8 deletions packages/metadata-protocol/src/reference-sites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,27 @@ export interface ReferenceSite {
readonly target: string;
}

/**
* A site whose record KEYS name the target only in their HEAD: each key is the
* target's name, then `separator`, then a part this index does not read. See
* {@link COMPOSITE_KEY_REFERENCE_SITES}.
*/
export interface CompositeKeyReferenceSite extends ReferenceSite {
/** Ends the target's name inside a key; the name is the text before its FIRST occurrence. */
readonly separator: string;
}

/** The derived index, plus the honest record of what could not be derived. */
export interface ReferenceSiteIndex {
/** Target metadata type → every site that may point at it. */
readonly byTarget: ReadonlyMap<string, readonly ReferenceSite[]>;
/**
* Target metadata type → every site that names it as the HEAD of a
* composite record key. Deliberately a SEPARATE map rather than more rows
* in {@link byTarget}: read {@link COMPOSITE_KEY_REFERENCE_SITES} on why
* those rows there would corrupt `duplicatePackage`.
*/
readonly compositeKeyByTarget: ReadonlyMap<string, readonly CompositeKeyReferenceSite[]>;
/**
* Declared types whose shape could NOT be read, so their references are
* genuinely not computable rather than absent. Pinned by a test — see the
Expand Down Expand Up @@ -262,27 +279,127 @@ const COMPOSITE_KEYED_TARGET_TYPES: readonly {
* answerable. Refusing those would be this card's harm inverted.
*/
function nameVocabularyRejectsKey(type: DeclaredMetadataType, separator: string): boolean {
const verdict = nameVocabularyVerdict(type, separator);
return verdict === 'unreadable' || verdict === 'rejects';
}

/** A declared type's JSON Schema, or `undefined` when it resolves no schema or cannot be converted. */
function metadataTypeJsonSchema(type: string): JsonSchemaNode | undefined {
const schema = getMetadataTypeSchema(type);
if (!schema) return true;
let json: JsonSchemaNode;
if (!schema) return undefined;
try {
json = z.toJSONSchema(schema, { unrepresentable: 'any', io: 'input' }) as JsonSchemaNode;
return z.toJSONSchema(schema, { unrepresentable: 'any', io: 'input' }) as JsonSchemaNode;
} catch {
return true;
return undefined;
}
}

/**
* What `type`'s declared `name` vocabulary says about `separator`:
* `'rejects'` (a readable pattern no name containing it can match), `'admits'`
* (a readable pattern some such name matches), `'unconstrained'` (no pattern,
* or one that does not compile) or `'unreadable'` (no schema to read). The two
* callers read the unknowns in OPPOSITE directions, each toward its own safe
* side, so the four answers stay distinct here.
*/
function nameVocabularyVerdict(
type: string,
separator: string,
): 'rejects' | 'admits' | 'unconstrained' | 'unreadable' {
const json = metadataTypeJsonSchema(type);
if (!json) return 'unreadable';
const nameNode = (json.properties as Record<string, JsonSchemaNode> | undefined)?.name;
const pattern = nameNode?.pattern;
if (typeof pattern !== 'string') return false;
if (typeof pattern !== 'string') return 'unconstrained';
let compiled: RegExp;
try {
compiled = new RegExp(pattern);
} catch {
return false;
return 'unconstrained';
}
// A two-segment probe is enough: if the declared vocabulary admits the
// separator at all, some composite key is representable and the type is
// not unanswerable BY KEY FORM.
return !compiled.test(`a${separator}b`);
return compiled.test(`a${separator}b`) ? 'admits' : 'rejects';
}

/**
* Sites whose record KEYS are COMPOSITE and name the target in their HEAD —
* today exactly one: `PermissionSetSchema.fields`, keyed `<object>.<field>`.
*
* ## The miss this closes
*
* The naming rule reads `permission.fields` as a site for `field` items (its
* property spells that type's plural) and never for an object. Every key of it
* names an object too: the security plugin reads a set's rules for an object
* by the key prefix `<object>.` (`PermissionEvaluator.getFieldPermissions`). So
* a set whose ONLY rule for an object was a field rule was missing from that
* object's answer, and the "Used by" panel could clear the object as "Safe to
* delete." while the set still masked its fields — the harm this module's
* header names.
*
* ## Why a seed, and why NOT more rows in `byTarget`
*
* The key form is stated in prose only — `fields` is `z.record(z.string(), …)`
* with the `<entity_name>.<field_name>` shape in its TSDoc, and its JSON Schema
* is indistinguishable from `object.fields`, which is keyed by the BARE field
* name. That is the {@link COMPOSITE_KEYED_TARGET_TYPES} class (a protocol fact
* no machine can read), and it gets the same treatment: a seed small enough to
* argue, with its premises DERIVED (see {@link compositeKeySiteHolds}).
*
* It is kept out of {@link ReferenceSiteIndex.byTarget} on purpose, measured
* both ways with a plain `{ permission, fields, object }` row there:
*
* - the answer did not change: the walk compares a WHOLE value with the
* target's name, and `refsrc_customer.full_name` is never `refsrc_customer`;
* - `duplicatePackage` broke: it reads `byTarget.get('object')` as a flat set of
* property NAMES that name an object anywhere in any item, so `fields` joined
* it and every copied object's own `fields` record — keyed by bare field
* name — had a key renamed after a renamed object. That copy rewrites a
* permission set's `fields` keys through its own shape-keyed step instead.
*
* ⛔ Do NOT grow this to "improve coverage", and do not move it into
* `byTarget`. The durable close is an annotation on the producer's record key
* in `packages/spec`, which derivation would then read like any other schema
* fact.
*
* The target is typed `'object'` alone: the walk splits a key on its FIRST
* separator, which is exact only because no object name can contain one — a
* premise argued for the object type, not for every type.
*/
const COMPOSITE_KEY_REFERENCE_SITES: readonly {
readonly fromType: DeclaredMetadataType;
readonly property: string;
readonly target: 'object';
readonly separator: string;
}[] = [
{ fromType: 'permission', property: 'fields', target: 'object', separator: '.' },
];

/**
* Do a composite-key seed's two premises still hold in the declared schemas?
*
* 1. the source type still declares `property` at its ROOT as a record — the
* keys are what is read, so a renamed or reshaped property must not keep a
* site that reads nothing, or reads something else;
* 2. the target's `name` vocabulary provably REJECTS the separator, so the
* text before a key's first separator is the whole target name and never a
* prefix of one (`ObjectSchema.name` is `^[a-z_][a-z0-9_]*$`).
*
* Both fail CLOSED, the opposite direction from {@link nameVocabularyRejectsKey}:
* an unreadable or unconstrained shape drops the site, because a split that
* cannot be proven exact would attribute a key to the wrong object. A seed that
* drops out turns the derivation pin red rather than shortening answers quietly.
*/
function compositeKeySiteHolds(site: (typeof COMPOSITE_KEY_REFERENCE_SITES)[number]): boolean {
if (nameVocabularyVerdict(site.target, site.separator) !== 'rejects') return false;
const json = metadataTypeJsonSchema(site.fromType);
if (!json) return false;
const defs = (json.$defs as Record<string, JsonSchemaNode> | undefined) ?? {};
const node = (json.properties as Record<string, JsonSchemaNode> | undefined)?.[site.property];
return alternatives(node, defs, new Set(), 0).some(
(alt) => alt.type === 'object' && !!alt.additionalProperties && typeof alt.additionalProperties === 'object',
);
}

/** `external_catalog` → `externalCatalog`. Identity for a type with no underscore. */
Expand Down Expand Up @@ -515,7 +632,22 @@ export function deriveReferenceSites(): ReferenceSiteIndex {
.map(({ type }) => type)
.sort();

return { byTarget, unwalkableSourceTypes: unwalkable.sort(), unanswerableTargetTypes: unanswerableTargets };
// Composite-key sites, filtered the same way: both ends declared, and both
// premises still true of the schemas. See COMPOSITE_KEY_REFERENCE_SITES.
const compositeKeyByTarget = new Map<string, CompositeKeyReferenceSite[]>();
for (const site of COMPOSITE_KEY_REFERENCE_SITES) {
if (!declared.has(site.fromType) || !declared.has(site.target) || !compositeKeySiteHolds(site)) continue;
const bucket = compositeKeyByTarget.get(site.target);
if (bucket) bucket.push({ ...site });
else compositeKeyByTarget.set(site.target, [{ ...site }]);
}

return {
byTarget,
compositeKeyByTarget,
unwalkableSourceTypes: unwalkable.sort(),
unanswerableTargetTypes: unanswerableTargets,
};
}

/**
Expand Down
Loading
Loading