Skip to content
34 changes: 34 additions & 0 deletions .changeset/20141-masked-on-read-field-types-declared.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@objectstack/spec': minor
'@objectstack/objectql': patch
---

`@objectstack/spec/data` declares which field types are masked on read — `MASKED_ON_READ_FIELD_TYPES` and `isMaskedOnReadFieldType(fieldType, managedBy)` (#20141)

Clause-②: yes

The protocol used to state this only in prose (the `FieldType` comments), so
two consumers each carried their own hand-written copy: objectql's
`collectMaskedReadFields` (the generic read mask and the echoed-mask write
guard) and the renderer's masked-type set. The fact is now declared once:

- `MASKED_ON_READ_FIELD_TYPES` — a deep-frozen per-type rule table:
`secret` is masked on every object; `password` is masked on every object
except `managedBy: 'better-auth'` ones. Each masked type carries its own
`exemptManagedBy` list, typed against `ObjectSchema.managedBy`'s enum.
- `isMaskedOnReadFieldType(fieldType, managedBy)` — the one reading of that
table. `managedBy` is a required argument (pass `undefined` when the object
has none), and exemptions fail closed: an absent or unlisted `managedBy`
never unmasks a masked type.

`@objectstack/objectql`: `collectMaskedReadFields` and
`collectMaskedPasswordFields` now ask `isMaskedOnReadFieldType` instead of
carrying their own `type === 'secret'` / `'password'` arms. No behaviour
change: the masked-on-read answer is identical for every `FieldType` ×
`managedBy` cell, pinned by a table test.

`@objectstack/spec`: `ObjectSchema.create()`'s author-time warning for a `password` field on a non-auth object now reads its `managedBy` exemption from `isMaskedOnReadFieldType` instead of hard-coding `'better-auth'`, so it follows the declaration; which objects and fields warn is unchanged.

A client that renders credential fields (show the mask, offer no copy) should
derive its set from `isMaskedOnReadFieldType` rather than keep its own list,
so the server's mask and the client's cannot drift apart.
95 changes: 94 additions & 1 deletion packages/objectql/src/secret-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ import {
collectMaskedPasswordFields,
collectMaskedReadFields,
} from './secret-fields.js';
import { SECRET_MASK as SPEC_SECRET_MASK } from '@objectstack/spec/data';
import {
SECRET_MASK as SPEC_SECRET_MASK,
FieldType,
ObjectSchema,
isMaskedOnReadFieldType,
} from '@objectstack/spec/data';
import type { ICryptoProvider, CryptoHandle, CryptoContext } from '@objectstack/spec/contracts';

// ---- minimal stub driver (equality-only WHERE) ----------------------------
Expand Down Expand Up @@ -659,3 +664,91 @@ describe('objectql aggregate() rejects credential fields (ADR-0100 / #3171)', ()
).rejects.toThrow(/password/);
});
});

/**
* [#20141] The masked-on-read answer for EVERY `FieldType × managedBy` cell —
* the security floor of lifting the fact into `@objectstack/spec`.
*
* Two pins, deliberately separate:
*
* - **The floor.** The expected column is written out here (secret always;
* password unless better-auth; nothing else), NOT read from the spec
* declaration: a pin that imported the answer to check the answer would be
* green through any edit to it. Which stored values leave the engine in
* clear is a security decision, so an edit to the declaration has to turn
* this red and be read.
* - **The agreement.** For every cell, the runtime collectors and the spec
* predicate give the same answer. This is what goes red when the runtime
* grows its own copy again (a `def.type === …` arm) instead of deriving.
* objectui keeps an interim copy until its re-bind card points it at the
* same declaration; from then on a runtime-only answer is a field masked
* by the server and drawn in clear by the client.
*/
describe('[#20141] masked-on-read: every FieldType × managedBy cell, derived from the spec declaration', () => {
const FIELD_TYPES: readonly string[] = FieldType.options;
const MANAGED_BY_BUCKETS: readonly string[] = (
ObjectSchema.shape.managedBy as unknown as { unwrap(): { options: readonly string[] } }
).unwrap().options;
const ABSENT = Symbol('managedBy key absent');
const MANAGED_BY: readonly unknown[] = [ABSENT, ...MANAGED_BY_BUCKETS, undefined, null, 'system', 'not-a-bucket'];
// Unvalidated metadata reaches these collectors (stored rows, hand-built
// schemas), so the table also covers types no FieldType names.
const OFF_ENUM_TYPES: readonly unknown[] = [undefined, null, 42, ['secret'], '', 'constructor', 'toString',
'__proto__', 'SECRET', 'Password'];

const objectWith = (type: unknown, managedBy: unknown): any => ({
name: 'cell',
...(managedBy === ABSENT ? {} : { managedBy }),
fields: { f: { type } },
});
const label = (type: unknown, managedBy: unknown) =>
`${JSON.stringify(type) ?? String(type)} × ${managedBy === ABSENT ? '(absent)' : String(managedBy)}`;
const specManagedBy = (managedBy: unknown) => (managedBy === ABSENT ? undefined : managedBy);

it('reads the live enums (the table below is not over an empty population)', () => {
expect(FIELD_TYPES).toContain('secret');
expect(FIELD_TYPES).toContain('password');
expect(FIELD_TYPES.length).toBeGreaterThan(40);
expect(MANAGED_BY_BUCKETS).toContain('better-auth');
expect(MANAGED_BY_BUCKETS.length).toBeGreaterThan(1);
});

it('the floor: secret is masked on every object, password on every object but better-auth, nothing else anywhere', () => {
let cells = 0;
let masked = 0;
for (const type of [...FIELD_TYPES, ...OFF_ENUM_TYPES]) {
for (const managedBy of MANAGED_BY) {
const expected = type === 'secret' || (type === 'password' && managedBy !== 'better-auth');
const schema = objectWith(type, managedBy);
expect(collectMaskedReadFields(schema).includes('f'), label(type, managedBy)).toBe(expected);
expect(collectMaskedPasswordFields(schema).includes('f'), `password half: ${label(type, managedBy)}`)
.toBe(expected && type === 'password');
cells += 1;
if (expected) masked += 1;
}
}
expect(cells).toBe((FIELD_TYPES.length + OFF_ENUM_TYPES.length) * MANAGED_BY.length);
// secret: every managedBy row; password: every row but one.
expect(masked).toBe(MANAGED_BY.length * 2 - 1);
});

it('the agreement: collectMaskedReadFields answers exactly what the spec predicate answers, cell for cell', () => {
for (const type of [...FIELD_TYPES, ...OFF_ENUM_TYPES]) {
for (const managedBy of MANAGED_BY) {
const schema = objectWith(type, managedBy);
const spec = isMaskedOnReadFieldType(type, specManagedBy(managedBy));
expect(collectMaskedReadFields(schema).includes('f'), label(type, managedBy)).toBe(spec);
expect(collectMaskedPasswordFields(schema).includes('f'), `password half: ${label(type, managedBy)}`)
.toBe(spec && type === 'password');
}
}
});

it('keeps declaration order and skips empty field definitions on a mixed object', () => {
const fields = { a: { type: 'text' }, b: { type: 'secret' }, c: { type: 'password' }, d: null, e: { type: 'secret' } };
expect(collectMaskedReadFields({ name: 'mixed', fields } as any)).toEqual(['b', 'c', 'e']);
expect(collectMaskedReadFields({ name: 'mixed', managedBy: 'better-auth', fields } as any)).toEqual(['b', 'e']);
expect(collectMaskedPasswordFields({ name: 'mixed', fields } as any)).toEqual(['c']);
expect(collectMaskedPasswordFields({ name: 'mixed', managedBy: 'better-auth', fields } as any)).toEqual([]);
});
});
28 changes: 21 additions & 7 deletions packages/objectql/src/secret-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
* ruling of 2026-08-12 on #7728.
*/

import type { ServiceObject } from '@objectstack/spec/data';
import { isMaskedOnReadFieldType, type ServiceObject } from '@objectstack/spec/data';

/**
* Prefix marking a persisted field value as a `sys_secret` handle ref rather
Expand Down Expand Up @@ -160,6 +160,18 @@ export class EmptyCredentialWriteError extends Error {
* generic read path: every `secret` field, plus every `password` field — the
* latter only when the object is **not** `managedBy: 'better-auth'`.
*
* [#20141] That sentence DESCRIBES the answer; it does not decide it. Which
* types are masked, and which `managedBy` buckets exempt which type, is declared
* once in `@objectstack/spec/data` (`MASKED_ON_READ_FIELD_TYPES`, read through
* `isMaskedOnReadFieldType`), and this collector asks that predicate per field.
* objectui keeps its interim `MASKED_FIELD_TYPES` copy until its own re-bind
* card lands; after that it reads this same declaration, and the server's mask
* and the client's cannot drift apart. ⛔ Do not add a `def.type === …` arm
* here: a type masked here and not in the declaration is a second copy, and
* once the renderer derives it is a field masked by the server and drawn in
* clear by the client. Change the declaration instead; the table test in
* `secret-fields.test.ts` pins every `FieldType × managedBy` answer.
*
* The better-auth exemption is deliberate: the auth subsystem reads its identity
* rows through the engine's find/findOne, and masking a credential column there
* would break login. Today no identity object even declares a `password`-typed
Expand All @@ -172,12 +184,10 @@ export class EmptyCredentialWriteError extends Error {
export function collectMaskedReadFields(schema: ServiceObject | undefined | null): string[] {
const fields = (schema as any)?.fields as Record<string, { type?: string }> | undefined;
if (!fields) return [];
const isBetterAuth = (schema as any)?.managedBy === 'better-auth';
const managedBy: unknown = (schema as any)?.managedBy;
const out: string[] = [];
for (const [name, def] of Object.entries(fields)) {
if (!def) continue;
if (def.type === 'secret') out.push(name);
else if (def.type === 'password' && !isBetterAuth) out.push(name);
if (def && isMaskedOnReadFieldType(def.type, managedBy)) out.push(name);
}
return out;
}
Expand All @@ -186,6 +196,10 @@ export function collectMaskedReadFields(schema: ServiceObject | undefined | null
* [#8559] Collect the names of `password`-typed fields on a **generic**
* (non-`better-auth`) object — exactly the `password` half of
* {@link collectMaskedReadFields}, kept beside it so the two cannot drift.
* [#20141] It asks the same spec predicate (`isMaskedOnReadFieldType`) for the
* exemption rather than restating it, so "the password fields that are masked"
* has one answer; the `'password'` arm is this collector's own scope, not a
* copy of the masked set.
*
* This is the scope of the empty-string refusal's `password` arm
* ({@link EmptyCredentialWriteError}): the ruling covers the fields that share
Expand All @@ -202,10 +216,10 @@ export function collectMaskedReadFields(schema: ServiceObject | undefined | null
export function collectMaskedPasswordFields(schema: ServiceObject | undefined | null): string[] {
const fields = (schema as any)?.fields as Record<string, { type?: string }> | undefined;
if (!fields) return [];
if ((schema as any)?.managedBy === 'better-auth') return [];
const managedBy: unknown = (schema as any)?.managedBy;
const out: string[] = [];
for (const [name, def] of Object.entries(fields)) {
if (def && def.type === 'password') out.push(name);
if (def && def.type === 'password' && isMaskedOnReadFieldType(def.type, managedBy)) out.push(name);
}
return out;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/api-surface/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@
"FieldSchema (const)",
"FieldType (const)",
"FieldType (type)",
"FieldTypeReadMask (interface)",
"FileLikeValue (type)",
"FileLikeValueSchema (const)",
"FilePersistenceConfig (type)",
Expand Down Expand Up @@ -401,6 +402,7 @@
"LocationValueSchema (const)",
"LogicalOperatorKey (type)",
"MANAGED_WRITE_VERB_AFFORDANCE (const)",
"MASKED_ON_READ_FIELD_TYPES (const)",
"MAX_BULK_PER_ROW_HOOK_ROWS (const)",
"MEASURE_FIELD_TYPES (const)",
"MONGO_OPTIONS_CREDENTIAL_PATHS (const)",
Expand Down Expand Up @@ -793,6 +795,7 @@
"isInjectedColumnDefinition (function)",
"isKnownFilterToken (function)",
"isLegacyApiMethod (function)",
"isMaskedOnReadFieldType (function)",
"isMultiValueField (function)",
"isNowDefaultToken (function)",
"isOrganizationUnique (function)",
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/export-origins/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@
"FieldScaleMeta": "src/data/field-scale.ts#FieldScaleMeta (interface)",
"FieldSchema": "src/data/field.zod.ts#FieldSchema (const)",
"FieldType": "src/data/field.zod.ts#FieldType (type)",
"FieldTypeReadMask": "src/data/masked-field-types.ts#FieldTypeReadMask (interface)",
"FileLikeValue": "src/data/field-value.zod.ts#FileLikeValue (type)",
"FileLikeValueSchema": "src/data/field-value.zod.ts#FileLikeValueSchema (const)",
"FilePersistenceConfig": "src/data/driver/memory.zod.ts#FilePersistenceConfig (type)",
Expand Down Expand Up @@ -391,6 +392,7 @@
"LocationValueSchema": "src/data/field-value.zod.ts#LocationValueSchema (const)",
"LogicalOperatorKey": "src/data/filter.zod.ts#LogicalOperatorKey (type)",
"MANAGED_WRITE_VERB_AFFORDANCE": "src/data/managed-api-affordance.ts#MANAGED_WRITE_VERB_AFFORDANCE (const)",
"MASKED_ON_READ_FIELD_TYPES": "src/data/masked-field-types.ts#MASKED_ON_READ_FIELD_TYPES (const)",
"MAX_BULK_PER_ROW_HOOK_ROWS": "src/data/bulk-write-hook-conformance.ts#MAX_BULK_PER_ROW_HOOK_ROWS (const)",
"MEASURE_FIELD_TYPES": "src/data/aggregation-policy.ts#MEASURE_FIELD_TYPES (const)",
"MONGO_OPTIONS_CREDENTIAL_PATHS": "src/data/driver/common.zod.ts#MONGO_OPTIONS_CREDENTIAL_PATHS (const)",
Expand Down Expand Up @@ -780,6 +782,7 @@
"isInjectedColumnDefinition": "src/data/injected-system-column-provenance.ts#isInjectedColumnDefinition (function)",
"isKnownFilterToken": "src/data/context-tokens.zod.ts#isKnownFilterToken (function)",
"isLegacyApiMethod": "src/data/api-derivation.ts#isLegacyApiMethod (function)",
"isMaskedOnReadFieldType": "src/data/masked-field-types.ts#isMaskedOnReadFieldType (function)",
"isMultiValueField": "src/data/field-value.zod.ts#isMultiValueField (function)",
"isNowDefaultToken": "src/data/default-value-tokens.ts#isNowDefaultToken (function)",
"isOrganizationUnique": "src/data/field.zod.ts#isOrganizationUnique (function)",
Expand Down
8 changes: 5 additions & 3 deletions packages/spec/src/data/field.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,17 @@ export const FieldType = z.enum([
// rest but masked to SECRET_MASK on read — the auth subsystem's one-way
// hashing applies only to its own identity tables, never to an authored
// 'password' field. Prefer 'secret' for reversible machine credentials. See
// ADR-0100.
// ADR-0100. Which types are masked on read, and the `managedBy: 'better-auth'`
// exemption for 'password', is DECLARED in `MASKED_ON_READ_FIELD_TYPES`
// (`./masked-field-types`) — this prose describes it, the table decides it.
'text', 'textarea', 'email', 'url', 'phone', 'password',
// Secret — reversible, encrypted-at-rest value (DB password, API key, token).
// UNLIKE 'password' (masked-on-read but plaintext at rest, or one-way hashed
// inside the auth subsystem), a 'secret' is round-tripped: the engine encrypts
// it on write via the registered ICryptoProvider, stores the ciphertext handle
// in `sys_secret`, persists only an opaque ref on the row, and masks it on
// read. Fail-closed: no provider ⇒ writes throw rather than persist cleartext.
// See ADR-0100.
// read (always — `MASKED_ON_READ_FIELD_TYPES`). Fail-closed: no provider ⇒
// writes throw rather than persist cleartext. See ADR-0100.
'secret',
// Rich Content
'markdown', 'html', 'richtext',
Expand Down
6 changes: 6 additions & 0 deletions packages/spec/src/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@ export * from './field.zod';
// the encrypted-FIELD path cannot desynchronise from the mask it sees on the
// settings REST path.
export * from './secret-mask';
// Which field types are masked on read (ADR-0100) — one per-type rule table,
// `managedBy` exemption included, read through one predicate (#20141). objectql's
// `collectMaskedReadFields` and `ObjectSchema.create()`'s password warning derive
// from it; objectui keeps its interim `MASKED_FIELD_TYPES` copy until its own
// re-bind card lands, and derives from it after that.
export * from './masked-field-types';
// The unknown-authoring-key lint's CORE — comparator, finding shape, curated
// guidance tables (#3786). Kept frontend-safe: the stack WALKER that imports
// every schema lives in kernel/metadata-authoring-lint.ts, so this subpath's
Expand Down
Loading
Loading