Skip to content

Commit 8828b9e

Browse files
os-zhuangclaude
andauthored
feat(spec): declare metadata.maskObjectFields and getMetadataReadableFields (ADR-0106 follow-through) (#6874)
* feat(spec): declare metadata.maskObjectFields and getMetadataReadableFields (#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 * docs(spec): regenerate the rest-server reference for the new metadata 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 19d8948 commit 8828b9e

8 files changed

Lines changed: 224 additions & 3 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): declare `metadata.maskObjectFields` and `getMetadataReadableFields` (#6622)
6+
7+
ADR-0106's metadata-plane field-level security shipped in #6612 with two members
8+
**honoured but undeclared** — deliberately, because `packages/spec` is the spec
9+
seat's surface and the implementing PR did not touch it. Both now have a
10+
declared seat. This is the declaration half only: no runtime behaviour changes,
11+
in either direction, in any deployment.
12+
13+
**`MetadataEndpointsConfigSchema.maskObjectFields`** — `z.boolean().default(true)`
14+
(ADR-0106 D8). The per-server switch for masking served object schemas to the
15+
calling user's readable fields. It was already read by `@objectstack/rest`'s
16+
`normalizeConfig` through a cast, following the `api.enableOpenApi` /
17+
`api.enableSearch` precedent, so a deployment that sets it has always been
18+
honoured. What the declaration adds is that the key is now **type-safe in
19+
`objectstack.config.ts`**, carries its documentation with it, appears in the
20+
generated config schema and reference docs, and — the part that was a real gap —
21+
**survives a parse**. `MetadataEndpointsConfigSchema` strips keys it does not
22+
declare, so an author who ran their config through the schema before handing it
23+
to the server lost the opt-out silently; that hole is closed. The default is
24+
`true`, matching the shipped behaviour, so no deployment moves.
25+
26+
**`ISecurityService.getMetadataReadableFields?`** (ADR-0106 D7). The
27+
metadata-plane sibling of `getReadableFields`: identical except that a caller
28+
resolving to **zero** permission sets goes through the same fallback-set
29+
resolution `/auth/me/permissions` uses instead of falling open to the full field
30+
set — so a guest-facing deployment's schema exposure is a deliberate
31+
permission-set decision rather than an accidental everything-default.
32+
`@objectstack/plugin-security` has implemented it since #6612 and
33+
`@objectstack/metadata-core` already feature-detects it, falling back to
34+
`getReadableFields`.
35+
36+
The member is **optional**, following the precedent #6841 set on this same
37+
contract: absence is a defined, handled state (a security service that predates
38+
ADR-0106 keeps its pre-ADR behaviour), so a required declaration would assert
39+
something the codebase deliberately declines to rely on — `packages/rest` types
40+
the whole service as `Partial<ISecurityService>`, and this contract's own header
41+
instructs consumers to feature-detect. Optional also prevents the mistake
42+
structurally: the unguarded call does not compile, so a consumer cannot skip the
43+
fallback by accident.

‎content/docs/references/api/rest-server.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ const result = BatchEndpointsConfigSchema.parse(data);
133133
| **prefix** | `string` | ✅ | URL prefix for metadata endpoints |
134134
| **enableCache** | `boolean` | ✅ | Enable HTTP cache headers (ETag, Last-Modified) |
135135
| **cacheTtl** | `integer` | ✅ | Cache TTL in seconds |
136+
| **maskObjectFields** | `boolean` | ✅ | [ADR-0106 D8] Mask served object schemas to the caller's readable fields |
136137
| **endpoints** | `{ types: boolean; items: boolean; item: boolean; schema: boolean }` | optional | Enable/disable specific endpoints |
137138

138139

@@ -170,7 +171,7 @@ const result = BatchEndpointsConfigSchema.parse(data);
170171
| :--- | :--- | :--- | :--- |
171172
| **api** | `{ version: string; basePath: string; apiPath?: string; enableCrud: boolean; … }` | optional | REST API configuration |
172173
| **crud** | `{ operations?: object; patterns?: Record<string, object>; dataPrefix: string; objectParamStyle: Enum<'path' \| 'query'> }` | optional | CRUD endpoints configuration |
173-
| **metadata** | `{ prefix: string; enableCache: boolean; cacheTtl: integer; endpoints?: object }` | optional | Metadata endpoints configuration |
174+
| **metadata** | `{ prefix: string; enableCache: boolean; cacheTtl: integer; maskObjectFields: boolean; … }` | optional | Metadata endpoints configuration |
174175
| **batch** | `{ maxBatchSize: integer; enableBatchEndpoint: boolean; operations?: object; defaultAtomic: boolean }` | optional | Batch endpoints configuration |
175176
| **routes** | `{ includeObjects?: string[]; excludeObjects?: string[]; nameTransform: Enum<'none' \| 'plural' \| 'kebab-case' \| 'camelCase'>; overrides?: Record<string, object> }` | optional | Route generation configuration |
176177
| **openApi31** | `never` | optional | [REMOVED] `RestServerConfig.openApi31` was removed in @objectstack/spec 17 (#4579, ADR-0049) — no runtime ever read it: the REST server forwards only `api`/`crud`/`metadata`/`batch`/`routes`, and the served /openapi.json is the pre-generated contract enriched with the live server URL and the registered objects, so webhook/callback definitions declared here never appeared in it. Delete the key. Config-driven OpenAPI 3.1 webhooks/callbacks documentation is a new capability and must arrive via the enforce route of ADR-0049 (a new ADR), not by re-declaring the key; for a real outbound webhook use `Webhook` from `@objectstack/spec/automation`. |

‎packages/spec/authorable-defaults/api.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
"api/MetadataCacheResponse:notModified = false",
104104
"api/MetadataEndpointsConfig:cacheTtl = 3600",
105105
"api/MetadataEndpointsConfig:enableCache = true",
106+
"api/MetadataEndpointsConfig:maskObjectFields = true",
106107
"api/MetadataEndpointsConfig:prefix = \"/meta\"",
107108
"api/MetadataExportRequest:format = \"json\"",
108109
"api/MetadataImportRequest:conflictResolution = \"skip\"",

‎packages/spec/authorable-surface/api.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,6 +1026,7 @@
10261026
"api/MetadataEndpointsConfig:cacheTtl",
10271027
"api/MetadataEndpointsConfig:enableCache",
10281028
"api/MetadataEndpointsConfig:endpoints",
1029+
"api/MetadataEndpointsConfig:maskObjectFields",
10291030
"api/MetadataEndpointsConfig:prefix",
10301031
"api/MetadataEvent:definition",
10311032
"api/MetadataEvent:id",

‎packages/spec/src/api/rest-server.test.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
RestServerConfig,
1515
type RestApiConfig as RestApiConfigType,
1616
type RestServerConfig as RestServerConfigType,
17+
type MetadataEndpointsConfig,
1718
} from './rest-server.zod';
1819

1920
describe('RestApiConfigSchema', () => {
@@ -269,6 +270,55 @@ describe('MetadataEndpointsConfigSchema', () => {
269270

270271
expect(config.endpoints?.schema).toBe(false);
271272
});
273+
274+
it('[ADR-0106 D8] maskObjectFields defaults to true — the mask is ON unless opted out', () => {
275+
// The default is the whole security posture of the key: a deployment that
276+
// never mentions `maskObjectFields` must still mask served object schemas.
277+
// Pinning the MATERIALIZED default (not just the declaration) is what makes
278+
// a later `.optional()` — which would hand `undefined` to the REST layer —
279+
// fail here rather than silently unmask every metadata read.
280+
const config = MetadataEndpointsConfigSchema.parse({});
281+
282+
expect(config.maskObjectFields).toBe(true);
283+
});
284+
285+
it('[ADR-0106 D8] maskObjectFields: false is the declared per-server opt-out', () => {
286+
// The opt-out has to SURVIVE the parse. Before this key had a declared seat,
287+
// the schema stripped it (zod's default posture for an undeclared key) and
288+
// the REST layer only saw it because it reads its config object raw, through
289+
// a cast. An author who parses their config first now keeps the opt-out.
290+
const optedOut = MetadataEndpointsConfigSchema.parse({ maskObjectFields: false });
291+
292+
expect(optedOut.maskObjectFields).toBe(false);
293+
294+
// Explicit `true` is a real answer too, not a no-op the parse discards.
295+
expect(MetadataEndpointsConfigSchema.parse({ maskObjectFields: true }).maskObjectFields).toBe(true);
296+
});
297+
298+
it('[ADR-0106 D8] maskObjectFields is authorable without a cast, and is a boolean (compile-time)', () => {
299+
// The declaration's REASON for existing: `objectstack.config.ts` authors the
300+
// key by name and `packages/rest`'s `normalizeConfig` reads it. Both of them
301+
// go through this input type, so this is the pin that says the key no longer
302+
// needs `(metadata as any)` to be reachable.
303+
const authored: MetadataEndpointsConfig = { maskObjectFields: false };
304+
const readAsBoolean: boolean | undefined = authored.maskObjectFields;
305+
expect(readAsBoolean).toBe(false);
306+
307+
// Optional on the INPUT side — omitting it is how a deployment takes the
308+
// default, and it must stay legal.
309+
const omitted: MetadataEndpointsConfig = { prefix: '/meta' };
310+
expect(omitted.maskObjectFields).toBeUndefined();
311+
312+
// …and it is a boolean switch, not a string one. Never invoked: its only job
313+
// is to make the compiler prove the seat is typed rather than `any`, which
314+
// is exactly what the cast it replaces could not do.
315+
const mustNotCompile = () => {
316+
// @ts-expect-error `maskObjectFields` is a boolean — a truthy string is not the opt-out
317+
const wrongType: MetadataEndpointsConfig = { maskObjectFields: 'false' };
318+
return wrongType;
319+
};
320+
expect(typeof mustNotCompile).toBe('function');
321+
});
272322
});
273323

274324
describe('BatchEndpointsConfigSchema', () => {

‎packages/spec/src/api/rest-server.zod.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,30 @@ export const MetadataEndpointsConfigSchema = lazySchema(() => z.object({
290290
* Cache TTL in seconds
291291
*/
292292
cacheTtl: z.number().int().default(3600).describe('Cache TTL in seconds'),
293-
293+
294+
/**
295+
* [ADR-0106 D8] Per-caller field-level masking of the OBJECT SCHEMAS this
296+
* server serves from `/meta` and `/metadata`.
297+
*
298+
* Default **on**: an object schema is projected onto the fields the CALLING
299+
* user may read, so a field they cannot read does not appear at all — not its
300+
* name, label, type, picklist options, formula, `visibleWhen` predicate,
301+
* `defaultValue`, nor the `requiredPermissions` capability guarding it.
302+
*
303+
* `false` opts this server out and serves the full schema to every
304+
* authenticated caller, as releases before ADR-0106 did. The change is
305+
* **disclosure only**: the data plane masks values and refuses forbidden
306+
* writes either way, and the console reads field affordances from
307+
* `/auth/me/permissions`, so toggling it never changes UI correctness.
308+
*
309+
* Deployment-wide counterpart: `OS_ALLOW_UNMASKED_OBJECT_METADATA=1`, which
310+
* also covers the runtime `/metadata` dispatcher (that path has no per-server
311+
* REST config to read). Either opt-out disables the mask; neither is needed
312+
* to keep it on.
313+
*/
314+
maskObjectFields: z.boolean().default(true)
315+
.describe('[ADR-0106 D8] Mask served object schemas to the caller\'s readable fields'),
316+
294317
/**
295318
* Enable specific metadata endpoints
296319
*/

‎packages/spec/src/contracts/security-service.test.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,63 @@ describe('Security Service Contract', () => {
9898
.resolves.toEqual(['id', 'name']);
9999
});
100100

101+
it('[ADR-0106 D7] getMetadataReadableFields is OPTIONAL — absence degrades to getReadableFields', async () => {
102+
// THE structural pin behind "a deployment whose security service predates
103+
// ADR-0106 keeps its pre-ADR behaviour". Optional is what makes that a
104+
// property of the TYPE: such a service still satisfies the contract, and a
105+
// consumer cannot reach the metadata-plane answer without first handling
106+
// the absent case.
107+
const withoutIt: ISecurityService = makeService({ getReadableFields: async () => ['id', 'name'] });
108+
expect(typeof withoutIt.getMetadataReadableFields).toBe('undefined');
109+
110+
// The unguarded call does not compile. Never invoked — its only job is to
111+
// make the COMPILER prove the point (invoking it would merely prove that
112+
// JavaScript throws on `undefined()`, which is the runtime symptom this
113+
// declaration exists to prevent).
114+
const mustNotCompileWithoutAGuard = () =>
115+
// @ts-expect-error possibly undefined — a consumer must feature-detect first
116+
withoutIt.getMetadataReadableFields('deal', { userId: 'u1' });
117+
expect(typeof mustNotCompileWithoutAGuard).toBe('function');
118+
119+
// The shape consumers actually write (`metadata-core`'s object-schema mask
120+
// resolver): prefer the metadata-plane answer, fall back to the data-plane
121+
// one. The fallback is never NARROWER than the metadata-plane answer, which
122+
// is why degrading here cannot hide columns a caller may see.
123+
const ask = typeof withoutIt.getMetadataReadableFields === 'function'
124+
? withoutIt.getMetadataReadableFields.bind(withoutIt)
125+
: withoutIt.getReadableFields.bind(withoutIt);
126+
await expect(ask('deal', { userId: 'u1' })).resolves.toEqual(['id', 'name']);
127+
});
128+
129+
it('[ADR-0106 D7] the metadata plane narrows where the data plane falls open', async () => {
130+
// The one respect in which the two methods differ, and the reason a second
131+
// method exists at all: a caller resolving to ZERO permission sets falls
132+
// OPEN on the data plane (mirroring the middleware, which skips its field
133+
// gate entirely) and resolves the fallback set on the metadata plane, so a
134+
// guest deployment's schema exposure is a deliberate permission-set
135+
// decision rather than an accidental everything-default.
136+
const service = makeService({
137+
getReadableFields: async () => ['id', 'name', 'secret'],
138+
getMetadataReadableFields: async (_object, context) =>
139+
context?.isSystem ? ['id', 'name', 'secret'] : ['id'],
140+
});
141+
142+
await expect(service.getReadableFields('deal', {})).resolves.toEqual(['id', 'name', 'secret']);
143+
await expect(service.getMetadataReadableFields?.('deal', {})).resolves.toEqual(['id']);
144+
145+
// A system context bypasses on BOTH planes.
146+
await expect(service.getMetadataReadableFields?.('deal', { isSystem: true }))
147+
.resolves.toEqual(['id', 'name', 'secret']);
148+
149+
// …and it inherits getReadableFields' two distinct empty answers, unchanged:
150+
// `undefined` = no answer (fall back to your own projection), `[]` = the real
151+
// answer that no field may be disclosed.
152+
const noAnswer = makeService({ getMetadataReadableFields: async () => undefined });
153+
await expect(noAnswer.getMetadataReadableFields?.('deal', { userId: 'u1' })).resolves.toBeUndefined();
154+
const nothingDisclosable = makeService({ getMetadataReadableFields: async () => [] });
155+
await expect(nothingDisclosable.getMetadataReadableFields?.('deal', { userId: 'u1' })).resolves.toEqual([]);
156+
});
157+
101158
it('a partial implementation is feature-detectable rather than wrong', () => {
102159
// Consumers probe (`typeof svc.getReadableFields === 'function'`) so an
103160
// implementation may omit a method it cannot honour and still be usable.

‎packages/spec/src/contracts/security-service.ts‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@
3333
* answers "which columns may this caller see" for presentation purposes; when
3434
* the object schema cannot be resolved it returns `undefined`, meaning
3535
* "no answer — use your own fallback", NOT "no fields are readable". An empty
36-
* array is a real answer and means the opposite: nothing is readable.
36+
* array is a real answer and means the opposite: nothing is readable. Its
37+
* metadata-plane sibling {@link ISecurityService.getMetadataReadableFields}
38+
* (ADR-0106 D7) reads the same two empty answers the same way.
3739
* - **Verdicts fail to ABSTENTION.** {@link ISecurityService.checkAuthoredRowWrite}
3840
* answers a question a composing caller may use to WIDEN, so its failure mode
3941
* is the one that changes nothing: `abstain`. It never reports `admit` for a
@@ -231,6 +233,49 @@ export interface ISecurityService {
231233
*/
232234
getReadableFields(object: string, context?: SecurityContext): Promise<string[] | undefined>;
233235

236+
/**
237+
* [ADR-0106 D7] The METADATA-PLANE variant of {@link getReadableFields}:
238+
* which field names may be DISCLOSED to `context` when an object SCHEMA is
239+
* served, as opposed to which columns of a row it may read.
240+
*
241+
* Identical to {@link getReadableFields} in every respect but one — a caller
242+
* that resolves to **zero** permission sets goes through the same fallback-set
243+
* resolution `/auth/me/permissions` uses (`security.fallbackPermissionSet`,
244+
* default `member_default`) instead of falling open to the full field set.
245+
*
246+
* **Why the two differ rather than converge.** `getReadableFields` mirrors the
247+
* engine middleware, which skips its whole field gate for a caller with no
248+
* permission sets; on the DATA plane, reporting a narrowing the enforcement
249+
* path would not apply is its own kind of drift, so falling open is the
250+
* correct, drift-free answer there. The metadata plane has no such symmetry to
251+
* preserve — the question is disclosure, and D7 rules that a public/guest
252+
* deployment's schema exposure must be a deliberate permission-set decision
253+
* rather than an accidental everything-default. It still falls open when the
254+
* fallback set itself resolves to nothing (no `member_default` in the
255+
* deployment at all): that is the "no FLS posture here" tier, not a restricted
256+
* caller.
257+
*
258+
* **Fails SOFT, with the same two distinct empty answers as
259+
* {@link getReadableFields}:** `undefined` is "no answer — use your own
260+
* fallback" (e.g. the object schema could not be resolved); `[]` is the real
261+
* answer that this caller may see NO field. A system context bypasses and
262+
* yields the full field set.
263+
*
264+
* **OPTIONAL, and absence is a defined state — not a bug.** A security service
265+
* that predates ADR-0106, or one that cannot honour the fallback resolution,
266+
* omits it; consumers feature-detect
267+
* (`typeof svc.getMetadataReadableFields === 'function'`) and fall back to
268+
* {@link getReadableFields}, which is the pre-ADR-0106 behaviour and never a
269+
* *narrower* answer. Declaring it optional is what makes that degradation a
270+
* property of the type rather than a promise in prose: the unguarded call does
271+
* not compile, so a consumer cannot skip the fallback by accident.
272+
*
273+
* The masking itself is gated separately by the deployment's D8 opt-outs
274+
* (`metadata.maskObjectFields: false`, `OS_ALLOW_UNMASKED_OBJECT_METADATA`);
275+
* this method only answers the projection question when a mask applies.
276+
*/
277+
getMetadataReadableFields?(object: string, context?: SecurityContext): Promise<string[] | undefined>;
278+
234279
/**
235280
* The effective permission-set NAMES for `context` — positions expanded and
236281
* the additive baseline applied, i.e. the same set the middleware enforces

0 commit comments

Comments
 (0)