Skip to content

plugin-sharing re-derives materializeDeclaredFields instead of importing it — a second copy of the declared-field binding contract, already diverged #8489

Description

@os-zhuang

Filed unassigned from the domain:services seat (#6021) while reviewing PR #8483 against the maintainer's 2026-08-06 ruling on #4953. ⛔ This records a finding only — no fix commitment, no pm:queue.

What is there

packages/plugins/plugin-sharing/src/share-link-service.ts defines its own declared-field binder:

function bindDeclaredFields(record: Record<string, unknown>, schema: any): Record<string, unknown> {
  const declared = schema?.fields;
  if (!declared || typeof declared !== 'object') return record;
  const bound: Record<string, unknown> = { ...record };
  for (const name of Object.keys(declared)) {
    if (!(name in bound)) bound[name] = null;
  }
  return bound;
}

Its own doc comment names what it is a copy of:

"The same materialisation the two server-side CEL gates in @objectstack/objectql do (materializeDeclaredFields, behind the validation and hook-condition evaluators)."

It is used by assertEligible to bind the record before evaluating publicSharing.eligibility.

Why this is a finding rather than a style nit

It could have imported the real helper. plugin-sharing/package.json lists @objectstack/objectql as a runtime dependencies entry (not a devDependency), and materializeDeclaredFields is exported from packages/objectql/src/declared-fields.ts. There is no structural obstacle here — unlike trigger-record-change, where objectql is dev-only.

The repo has already written down why duplication is the failure. packages/objectql/src/cel-fault.ts:

"the only durable way to keep two messages worded alike is to stop writing them twice — the same argument that put declared-fields.js#materializeDeclaredFields in front of every server-side evaluator"

It has already diverged. The local copy early-returns record unchanged when schema.fields is absent or not an object. The shared helper is not reached that way by its callers, which gate on the groundTruth rule instead (fetch-the-prior-row, "do not fabricate"). So the two answer differently in at least one input class today.

⚠️ The live exposure is the pinned !has(...) reversal. PR #6454 deliberately changed what !has(record.{declared field}) means once bindings are materialised, pinned by two tests, on the grounds that has() guards an UNDECLARED key and never an empty value (the declared-fields.ts contract since #4649). A hand-rolled mirror does not inherit that change, nor the next one. The entire point of the seam programme in #4953 is that one predicate means one thing on every server-side surface.

Blast radius, stated honestly

This is not a live security hole, and it should not be graded as one. assertEligible fails closed: an unevaluable predicate raises ELIGIBILITY_UNEVALUABLE and refuses to mint the link. So the divergence's current worst case is a refused share link, not a leaked one. The bindDeclaredFields bail on a missing schema lands in the same safe direction.

The cost is drift, not breach: this is the surface where a future change to the shared contract silently fails to apply, in a gate whose verdicts are security-relevant.

Suggested shape (⛔ not a prescription — the owning lane decides)

The convergent option, which also resolves the same question now open on PR #8483: move materializeDeclaredFields into @objectstack/core, which objectql, plugin-sharing and trigger-record-change all already depend on at runtime, and re-export from objectql for compatibility. That lets all three seams share one copy without promoting any dependency.

Counting the seams for whoever picks this up: objectql has 2 importers (hook-wrappers.ts, validation/rule-validator.ts); this file is copy #2 of the pattern; PR #8483 proposes copy #3.

Related

Verified by reading share-link-service.ts and both package.json files on main, not by code search alone.

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions