Skip to content

Commit 5cb765b

Browse files
committed
feat(spec): warnGenericPasswordFields reads its exemption from the masked-on-read declaration
ObjectSchema.create()'s author-time password warning no longer hard-codes managedBy 'better-auth'; it warns exactly where isMaskedOnReadFieldType ('password', managedBy) says the read mask applies. Behaviour unchanged (same warn/no-warn per managedBy x field-shape row). A pin in object.test.ts ties the warning to the declaration. Comments no longer say objectui derives from the declaration today: it keeps its interim copy until its re-bind card. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 087ce04 commit 5cb765b

7 files changed

Lines changed: 70 additions & 15 deletions

File tree

‎.changeset/20141-masked-on-read-field-types-declared.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ carrying their own `type === 'secret'` / `'password'` arms. No behaviour
2727
change: the masked-on-read answer is identical for every `FieldType` ×
2828
`managedBy` cell, pinned by a table test.
2929

30+
`@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.
31+
3032
A client that renders credential fields (show the mask, offer no copy) should
3133
derive its set from `isMaskedOnReadFieldType` rather than keep its own list,
3234
so the server's mask and the client's cannot drift apart.

‎packages/objectql/src/secret-fields.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -679,9 +679,10 @@ describe('objectql aggregate() rejects credential fields (ADR-0100 / #3171)', ()
679679
* this red and be read.
680680
* - **The agreement.** For every cell, the runtime collectors and the spec
681681
* predicate give the same answer. This is what goes red when the runtime
682-
* grows its own copy again (a `def.type === …` arm) instead of deriving:
683-
* the renderer derives from the spec too, so a runtime-only answer is a
684-
* field masked by the server and drawn in clear by the client.
682+
* grows its own copy again (a `def.type === …` arm) instead of deriving.
683+
* objectui keeps an interim copy until its re-bind card points it at the
684+
* same declaration; from then on a runtime-only answer is a field masked
685+
* by the server and drawn in clear by the client.
685686
*/
686687
describe('[#20141] masked-on-read: every FieldType × managedBy cell, derived from the spec declaration', () => {
687688
const FIELD_TYPES: readonly string[] = FieldType.options;

‎packages/objectql/src/secret-fields.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,10 +164,12 @@ export class EmptyCredentialWriteError extends Error {
164164
* types are masked, and which `managedBy` buckets exempt which type, is declared
165165
* once in `@objectstack/spec/data` (`MASKED_ON_READ_FIELD_TYPES`, read through
166166
* `isMaskedOnReadFieldType`), and this collector asks that predicate per field.
167-
* objectui's renderer derives from the same declaration, so the server's mask
167+
* objectui keeps its interim `MASKED_FIELD_TYPES` copy until its own re-bind
168+
* card lands; after that it reads this same declaration, and the server's mask
168169
* and the client's cannot drift apart. ⛔ Do not add a `def.type === …` arm
169-
* here: a type masked here and not in the declaration is drawn in clear by the
170-
* renderer. Change the declaration instead; the table test in
170+
* here: a type masked here and not in the declaration is a second copy, and
171+
* once the renderer derives it is a field masked by the server and drawn in
172+
* clear by the client. Change the declaration instead; the table test in
171173
* `secret-fields.test.ts` pins every `FieldType × managedBy` answer.
172174
*
173175
* The better-auth exemption is deliberate: the auth subsystem reads its identity

‎packages/spec/src/data/index.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,10 @@ export * from './field.zod';
137137
// settings REST path.
138138
export * from './secret-mask';
139139
// Which field types are masked on read (ADR-0100) — one per-type rule table,
140-
// `managedBy` exemption included, read through one predicate. objectql's
141-
// `collectMaskedReadFields` and objectui's renderer derive from it instead of
142-
// each owning a hand-written copy (#20141).
140+
// `managedBy` exemption included, read through one predicate (#20141). objectql's
141+
// `collectMaskedReadFields` and `ObjectSchema.create()`'s password warning derive
142+
// from it; objectui keeps its interim `MASKED_FIELD_TYPES` copy until its own
143+
// re-bind card lands, and derives from it after that.
143144
export * from './masked-field-types';
144145
// The unknown-authoring-key lint's CORE — comparator, finding shape, curated
145146
// guidance tables (#3786). Kept frontend-safe: the stack WALKER that imports

‎packages/spec/src/data/masked-field-types.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@
2828
* which must not draw a masked value in clear or offer to copy it). A type
2929
* added to one copy and not the other is masked by the server and drawn in
3030
* clear by the client, or the reverse. `spec` is the contract both already
31-
* depend on, so the fact is declared here and both derive from it.
31+
* depend on, so the fact is declared here. objectql's collectors and
32+
* `ObjectSchema.create()`'s `password` warning derive from it now; objectui
33+
* keeps its interim `MASKED_FIELD_TYPES` until its own re-bind card lands, and
34+
* that card points it at {@link isMaskedOnReadFieldType}.
3235
*
3336
* # Shape — a per-type rule, read through one predicate
3437
*

‎packages/spec/src/data/object.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { resolveInjectedSystemColumns } from './injected-system-columns';
1111
import { projectPublishedJsonSchema } from '../../scripts/lib/refinement-projection';
1212
import { collectDroppedRefinements } from '../../scripts/lib/dropped-refinements';
1313
import { Field } from './field.zod';
14+
import { isMaskedOnReadFieldType } from './masked-field-types';
1415
import type { StateMachineValidation } from './validation.zod';
1516

1617
describe('ObjectCapabilities', () => {
@@ -2459,6 +2460,38 @@ describe('ObjectSchema.create() password-field author warning (ADR-0100)', () =>
24592460
const msg = warn.mock.calls[0]?.[0] as string;
24602461
expect(msg).toContain('ackPlaintextMasking');
24612462
});
2463+
2464+
// [#20141] The warning's exemption is the masked-on-read declaration's, not a
2465+
// second copy: for every `managedBy` bucket (and none), an un-acknowledged
2466+
// `password` field warns exactly when `isMaskedOnReadFieldType('password', …)`
2467+
// says the read mask applies. If `MASKED_ON_READ_FIELD_TYPES.password
2468+
// .exemptManagedBy` changes, this stays green only because the warning follows;
2469+
// a warning that hard-codes its own bucket goes red here.
2470+
describe('[#20141] the exemption is read from MASKED_ON_READ_FIELD_TYPES', () => {
2471+
const buckets = (
2472+
ObjectSchema.shape.managedBy as unknown as { unwrap(): { options: readonly string[] } }
2473+
).unwrap().options;
2474+
const PW_WARNING = "use type 'password' on a non-auth object";
2475+
const warnsFor = (managedBy: string | undefined): boolean => {
2476+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
2477+
ObjectSchema.create({
2478+
name: `adr0100_decl_${(managedBy ?? 'absent').replace(/-/g, '_')}`,
2479+
...(managedBy === undefined ? {} : { managedBy: managedBy as never }),
2480+
fields: { pw: { type: 'password' } },
2481+
});
2482+
const fired = warn.mock.calls.some((c) => String(c[0]).includes(PW_WARNING));
2483+
warn.mockRestore();
2484+
return fired;
2485+
};
2486+
2487+
it('warns on exactly the buckets where the declaration masks password (the agreement)', () => {
2488+
expect(buckets.length).toBeGreaterThan(1); // the enum really was read
2489+
for (const managedBy of [undefined, ...buckets]) {
2490+
expect(warnsFor(managedBy), `managedBy ${managedBy ?? '(absent)'}`)
2491+
.toBe(isMaskedOnReadFieldType('password', managedBy));
2492+
}
2493+
});
2494+
});
24622495
});
24632496

24642497
// ---------------------------------------------------------------------------

‎packages/spec/src/data/object.zod.ts‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ import { retiredKey } from '../shared/retired-key';
1818
import { refuseRecordProtoKey } from '../shared/record-proto-key-guard';
1919
import { bannedKeys } from '../shared/refinement-projection';
2020
import { FIELD_GROUP_KEY_PATTERN } from './field-group-layout';
21+
// The masked-on-read declaration (#20141); `warnGenericPasswordFields` reads its
22+
// `password` exemption rather than restating it. No runtime cycle: that module's
23+
// only imports are type-only.
24+
import { isMaskedOnReadFieldType } from './masked-field-types';
2125
export const ApiMethod = z.enum([
2226
'get', 'list', // Read
2327
'create', 'update', 'delete', // Write
@@ -2574,17 +2578,26 @@ const warnedPasswordObjects = new Set<string>();
25742578
* A warning, not an error: `password` now has a defined generic-path contract,
25752579
* and the field-zoo example intentionally exercises every field type — a hard
25762580
* error would be self-inflicted breakage. Deduped per object name so a schema
2577-
* imported many times warns once. `managedBy: 'better-auth'` objects are exempt,
2578-
* as are fields that opt in with `ackPlaintextMasking: true` — the author's
2579-
* explicit "this is intended" acknowledgment (#3420), which lets a deliberate
2580-
* demo/design (e.g. the showcase field-zoo) start with zero warnings.
2581+
* imported many times warns once. Objects whose `managedBy` exempts `password`
2582+
* from the read mask are exempt (today `better-auth`), as are fields that opt in
2583+
* with `ackPlaintextMasking: true` — the author's explicit "this is intended"
2584+
* acknowledgment (#3420), which lets a deliberate demo/design (e.g. the showcase
2585+
* field-zoo) start with zero warnings.
2586+
*
2587+
* [#20141] The exemption is READ from the masked-on-read declaration
2588+
* (`MASKED_ON_READ_FIELD_TYPES`, via `isMaskedOnReadFieldType`), never restated:
2589+
* the warning describes a `password` that is masked on read yet plaintext at
2590+
* rest, so it fires exactly where the read mask applies to `password`. If that
2591+
* declaration's `exemptManagedBy` changes, this warning follows it. ⛔ Not
2592+
* widened to `secret` — a `secret` is encrypted at rest, which is the whole
2593+
* point of steering authors to it.
25812594
*/
25822595
function warnGenericPasswordFields(
25832596
objectName: unknown,
25842597
fields: unknown,
25852598
managedBy: unknown,
25862599
): void {
2587-
if (managedBy === 'better-auth') return;
2600+
if (!isMaskedOnReadFieldType('password', managedBy)) return;
25882601
if (!fields || typeof fields !== 'object') return;
25892602
const passwordFields = Object.entries(
25902603
fields as Record<string, { type?: string; ackPlaintextMasking?: boolean }>,

0 commit comments

Comments
 (0)