feat(spec): declare metadata.maskObjectFields and getMetadataReadableFields (ADR-0106 follow-through) - #6874
Merged
Conversation
…Fields (#6622) ADR-0106's metadata-plane FLS shipped in #6612 with two members honoured but undeclared, deliberately: packages/spec is the spec seat's surface. This is the declaration half — no runtime behaviour changes. - MetadataEndpointsConfigSchema.maskObjectFields: z.boolean().default(true) (ADR-0106 D8). Already read by @objectstack/rest through a cast; the seat makes it type-safe in objectstack.config.ts and, unlike before, lets the opt-out survive a parse (the schema strips undeclared keys). - ISecurityService.getMetadataReadableFields? (ADR-0106 D7). Optional, per the precedent #6841 set on this contract: absence is a defined state that degrades to getReadableFields, and the unguarded call must not compile. Baselines authorable-surface/api.json and authorable-defaults/api.json are regenerated by the spec build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01115GjksiLKTEth6gZZMcvG
… key `content/docs/references/api/rest-server.mdx` is generated from the spec schemas (`gen:schema` + `gen:docs`); `check:docs` flags it as out of date once `MetadataEndpointsConfigSchema` gains a key. Regenerated, not hand-edited. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01115GjksiLKTEth6gZZMcvG
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 112 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
August 9, 2026 03:17
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6622
Part of #3682 (ADR-0106 metadata-plane FLS). PR #6612 shipped two members honoured but undeclared, deliberately:
packages/specis the spec seat's surface, so the implementing PR did not touch it. This is the declaration half, and it closes both in one PR as the card asks.No runtime behaviour changes, in either direction, in any deployment. Both members are already implemented and already read; what moves is the type surface, the generated reference, and one authoring path that was silently lossy (below).
1.
MetadataEndpointsConfigSchema.maskObjectFields(ADR-0106 D8)The per-server switch for masking served object schemas to the calling user's readable fields.
@objectstack/rest'snormalizeConfighas read it since #6612 through(metadata as any).maskObjectFields, following theapi.enableOpenApi/api.enableSearchprecedent, so a deployment that sets it has always been honoured.Three things the seat adds, one of which is a real gap rather than ergonomics:
objectstack.config.ts, carries its documentation with it, and appears in the generated reference (content/docs/references/api/rest-server.mdx, regenerated in commit 2 — generated file, not hand-edited).MetadataEndpointsConfigSchemais a plainz.object, i.e. zod'sstripposture: an undeclared key is discarded and the parse still succeeds. Nothing in the runtime parsesRestServerConfigSchematoday —normalizeConfigreceives the config object raw, which is exactly why the cast worked — but an author who ran their own config through the schema before handing it to the server lostmaskObjectFields: falsesilently, with a successful parse and an unmasked deployment. That hole is closed, and it is pinned (maskObjectFields: falsesurvives.parse()).true, matching the shipped behaviour andisObjectSchemaMaskingEnabled'sconfigured === falseopt-out semantics, so no deployment moves. The deployment-wideOS_ALLOW_UNMASKED_OBJECT_METADATAescape hatch is unchanged and documented on the key.2.
ISecurityService.getMetadataReadableFields?(ADR-0106 D7)The metadata-plane sibling of
getReadableFields: identical in every respect but one — a caller resolving to zero permission sets goes through the same fallback-set resolution/auth/me/permissionsuses (security.fallbackPermissionSet, defaultmember_default) instead of falling open to the full field set, so a guest-facing deployment's schema exposure is a deliberate permission-set decision rather than an accidental everything-default.@objectstack/plugin-securityhas implemented it since #6612 and@objectstack/metadata-core'sresolveObjectSchemaMaskPosturealready feature-detects it, falling back togetReadableFields.Same shape as
loadDiagnosed/getDiagnosed(#4127 batch 4 / #6051): call sites and implementation already agree, only the contract was missing.Optional, not required — following #6841
#6841 landed
checkAuthoredRowWrite?as optional on this same contract three commits before this branch, and recorded the counter-precedent (#6428 declaredISharingService.checkEdit/checkDeleteas required members with runtime feature detection). I follow #6841, and the reasons transfer without weakening:metadata-corereads the missing method as "usegetReadableFields". A required declaration would assert something the ruling explicitly names as legitimate.packages/resttypes the whole service asPartial<ISecurityService>.svc.getMetadataReadableFields(...)unguarded compiles under a required declaration and throws against any partial implementation; under an optional one it does not compile. There is a@ts-expect-errorpin for exactly this.One asymmetry worth naming, since it is the only place the two cards differ: degrading here is never a narrowing. The fallback (
getReadableFields) is equal or wider than the metadata-plane answer, so absence cannot hide a column a caller may see — it can only decline the extra tightening D7 adds for zero-permission-set callers. That is the pre-ADR-0106 behaviour, which is the correct thing for a deployment that never opted into D7 to get.Strictness-ledger judgement: neither member needs it
The card left this to the spec seat. My call is no ledger treatment, for two independent reasons:
ISecurityServiceis a TypeScript interface, not a zod object. The 未知键静默剥离仍是全仓默认:把 #3405 的 strict 收紧从一个 schema 推广到整个可授权面(ADR-0078 完整性闸门) #4001 ledger classifies object sites by unknown-key posture; there is no site here to classify.maskObjectFieldsis added inside an existingstrip-posturez.objectsite. The ledger counts sites, not keys, so this PR adds zero sites and shifts no count. TighteningMetadataEndpointsConfigSchemato.strict()would be a separate campaign step with its own compatibility question (it would start rejecting configs that pass extra keys today), and riding it in here would hide a behaviour change inside a declaration PR.pnpm --filter @objectstack/spec check:strictness-ledgerpasses unchanged, which is the measurement behind both claims.The
(metadata as any)cast inpackages/rest: measured removable, deliberately not removed hereThe dispatch asked me to measure this and state a call. Measured, on this branch, with the freshly built spec dist:
tsc --noEmitinpackages/restreports no new error — only the two pre-existing, unrelatedsrc/package-routes.tsstring | string[]errors, byte-identical before and after. The cast is genuinely vestigial now. The probe was reverted;git diff packages/rest/is empty.Call: out of scope for this PR. The card is claimed with a
packages/spec-only file surface, and that surface exists because of same-file serialization in the spec lane (this card already waited on #6841 for one shared file). Reaching intopackages/rest/src/rest-server.ts— one of the hottest files in the repo — to delete one cast trades a conflict risk against zero functional gain. The precedent agrees:api.enableOpenApihas had a declared seat inrest-server.zod.tssince long before this PR andnormalizeConfigstill reads it through a cast, so a leftover cast beside a declared key is an established, harmless state here rather than a defect this PR introduces.Two follow-up cleanups, both recorded on #6622 rather than done here, and both now purely cosmetic:
packages/rest/src/rest-server.ts— drop the(metadata as any)cast (and, while there, the same forapi.enableOpenApi/api.enableSearch), and shorten the comment that explains why the cast exists.packages/plugins/plugin-security/src/security-plugin.ts— theObject.assignregistration comment says the spec seat "is a separate change"; that is now stale prose, and the extension could be folded into the typedsecurityServiceliteral. TheObject.assignitself still type-checks correctly against the new contract either way (verified below).Reverse verification — the pin can go red
A pin that cannot fail is not a pin. Feeding a wrong-typed implementation to plugin-security's typed
securityServiceliteral turns its typecheck red against the freshly built spec dist, which is the check that the contract addition is being read from the new artefact and not a stale one:The probe was restored byte-identically (confirmed by an empty
git diffon the file), andpnpm --filter @objectstack/plugin-security run typecheckis clean again.The config half carries the same instrument at source level: a
@ts-expect-errorpin thatmaskObjectFields: 'false'(a truthy string, the classic config typo) does not compile, so the seat is proven typed rather thanany.Tests
packages/spec/src/api/rest-server.test.ts— 3 cases:truematerializes through.parse({})(so a later.optional(), which would handundefinedto the REST layer, fails here instead of silently unmasking every metadata read);maskObjectFields: falsesurvives the parse, and explicittrueis a real answer rather than a discarded no-op;@ts-expect-erroron a string).packages/spec/src/contracts/security-service.test.ts— 2 cases:@ts-expect-error), and the exact prefer-then-fall-back shapemetadata-corewrites;isSystembypassing on both planes, and the two distinct empty answers (undefined= no answer,[]= disclose nothing) inherited unchanged.Gates
Every
check:*in.github/workflows/lint.ymlwas enumerated and run one by one, in the foreground, pluscheck:strictness-ledgerfromspec-liveness-check.yml. All pass.check:docsfailed once, as expected —content/docs/references/api/rest-server.mdxis generated from the spec schemas and goes stale the momentMetadataEndpointsConfigSchemagains a key; regenerated withgen:schema+gen:docson a clean tree (never in a merge state, never hand-edited) and committed separately.The spec build regenerated
packages/spec/authorable-surface/api.jsonandauthorable-defaults/api.json(one line each:api/MetadataEndpointsConfig:maskObjectFieldsand… = true). Expected, committed, not reverted.Consumer sweep — prefix direction, i.e. spec and its dependents, run after the closures were built (
turbo run build --filter='./packages/*' --filter='./examples/*^...', then the ledgered--filter='./packages/*' --filter='./packages/*/*', so nothing typechecks against a staledist):pnpm --filter '...@objectstack/spec' run typecheckover 74 packages — clean. Full test suites for spec and the three packages that actually consume these two surfaces:@objectstack/spec8908 passed,@objectstack/rest1109 passed,@objectstack/plugin-security861 passed,@objectstack/metadata-core124 passed.Boundaries honoured
packages/restorpackages/plugins/plugin-securityedits — measured, deferred, recorded above.content/docs/releases/edits; the changeset (.changeset/adr-0106-declarations.md,@objectstack/specminor) is this PR's input to the release notes.Generated by Claude Code