Skip to content

Commit 833ed84

Browse files
os-zhuangclaude
andauthored
fix(spec): declare the eight-bullet credential read mask once, in spec (#7572) (#7759)
`SECRET_MASK` (objectql, the encrypted-FIELD read path) and `SETTINGS_SECRET_MASK` (service-settings, the settings REST read boundary) were two byte-identical literals bound by nothing. An edit to either would desynchronise the two masked reads a console sees, with both packages' suites still green — each asserted against its own copy. Hoist the mask into `@objectstack/spec` (`data/secret-mask.ts`, alongside the ADR-0100 surface in `data/field.zod.ts` / `data/object.zod.ts`); objectql re-exports it so its public API is unchanged, and service-settings aliases it as `SETTINGS_SECRET_MASK`. No objectql dependency was added to the settings service — spec is already in its runtime graph via platform-objects. Pins: byte + source-spelling pin at the declaration; an identity pin on each side that goes red if a local literal is re-introduced with different bytes. The far-side literal pins in plugin-audit / driver-memory keep their own copies on purpose. Co-authored-by: Claude <noreply@anthropic.com>
1 parent ec5a125 commit 833ed84

10 files changed

Lines changed: 290 additions & 18 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": patch
4+
"@objectstack/service-settings": patch
5+
---
6+
7+
fix(spec): declare the eight-bullet credential read mask once, in spec (#7572)
8+
9+
The string a client sees in place of a credential it may not read back was
10+
declared **twice**, byte-identical by convention only:
11+
12+
- `SECRET_MASK` in `@objectstack/objectql` — the encrypted-**field** read mask on
13+
the generic CRUD path (ADR-0100 §A/§B);
14+
- `SETTINGS_SECRET_MASK` in `@objectstack/service-settings` — the settings REST
15+
read boundary, added by #7522.
16+
17+
Nothing bound them. An edit to either literal would desynchronise the two masked
18+
reads a console sees, and the break would be invisible from both sides: each
19+
package asserted against its own copy, so both suites stay green while the two
20+
surfaces disagree. That matters more than a cosmetic mismatch — the console
21+
renders "configured vs not configured" from this value and echoes it back
22+
unchanged on save, and both write paths read that echo as "unchanged"
23+
(ADR-0100 §B3). A drifted mask silently turns an unchanged form round-trip into a
24+
real write of the mask's literal text over a live credential.
25+
26+
**What changed.** The mask is declared once, in `@objectstack/spec` — the
27+
contract face both sides already depend on — as `SECRET_MASK` in
28+
`spec/src/data/secret-mask.ts`, alongside the rest of the ADR-0100 surface
29+
(`data/field.zod.ts`, `data/object.zod.ts`). Both readers now import that one
30+
declaration:
31+
32+
- `@objectstack/objectql` **re-exports** it, so its public API is byte-for-byte
33+
unchanged — `SECRET_MASK` is still exported from the package root and from
34+
`core`, with the same name, value and literal type. No consumer changes.
35+
- `@objectstack/service-settings` aliases it as `SETTINGS_SECRET_MASK`, keeping
36+
the name that package publishes and every existing import of it working.
37+
38+
**The framework-agnostic property of the settings service is intact.** #7522
39+
declined to import the constant because reaching it meant depending on
40+
`@objectstack/objectql`, the whole data engine — that reasoning was right and
41+
still holds; no objectql import was added. It never applied to `@objectstack/spec`,
42+
which is already a dependency of the package and already in its runtime graph
43+
(`manifest.ts``@objectstack/platform-objects/system``@objectstack/spec/data`).
44+
45+
**New public API:** `SECRET_MASK` on `@objectstack/spec/data`. Additive — nothing
46+
was removed or renamed on any package.
47+
48+
The literal keeps its deliberate spelling (eight U+2022 BULLETs written out, not
49+
an escape or a `.repeat(8)`), so a grep for the mask a client actually received
50+
still lands on the declaration; a source-level pin holds that, next to a byte pin
51+
on the value. The far-side literal pins in `plugin-audit` and `driver-memory` are
52+
deliberately left restating the mask — a pin whose job is to catch the constant
53+
changing must not import the constant.

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import { describe, it, expect, beforeEach } from 'vitest';
1414
import { ObjectQL } from './engine.js';
1515
import { SECRET_MASK, isSecretRef } from './secret-fields.js';
16+
import { SECRET_MASK as SPEC_SECRET_MASK } from '@objectstack/spec/data';
1617
import type { ICryptoProvider, CryptoHandle, CryptoContext } from '@objectstack/spec/contracts';
1718

1819
// ---- minimal stub driver (equality-only WHERE) ----------------------------
@@ -146,6 +147,27 @@ async function buildEngine(withCrypto: boolean) {
146147
return { engine, stores, crypto, driver };
147148
}
148149

150+
/**
151+
* [#7572] This package no longer DECLARES the read mask — it re-exports the one
152+
* `@objectstack/spec` declares, so the encrypted-field path and the settings
153+
* REST path cannot serve two different masks.
154+
*
155+
* The pin restates the literal on purpose. Every assertion below compares an
156+
* engine read against the imported `SECRET_MASK`, which stays green whatever
157+
* that constant says; only a restated copy can catch the mask's bytes changing
158+
* under this package, and only the identity check can catch the re-export being
159+
* quietly replaced by a fresh local literal — the exact shape #7572 removed.
160+
*/
161+
describe('objectql SECRET_MASK re-export (#7572)', () => {
162+
it('is the spec declaration, not a copy', () => {
163+
expect(SECRET_MASK).toBe(SPEC_SECRET_MASK);
164+
});
165+
166+
it('is the eight-bullet mask ADR-0100 pins', () => {
167+
expect(SECRET_MASK).toBe('••••••••');
168+
});
169+
});
170+
149171
describe('objectql secret-field channel', () => {
150172
let ctx: Awaited<ReturnType<typeof buildEngine>>;
151173
beforeEach(async () => { ctx = await buildEngine(true); });

packages/objectql/src/secret-fields.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,17 @@ export const SECRET_REF_PREFIX = 'secret:';
5050
* Value returned in place of a secret field on a normal read. Indicates
5151
* "a secret is set" without leaking the handle id or the plaintext. A field
5252
* with no stored secret resolves to `null` instead.
53+
*
54+
* Declared in `@objectstack/spec` and re-exported here (#7572), because the
55+
* same mask is the contract on a second surface this package cannot see: the
56+
* settings REST read boundary in `@objectstack/service-settings`, which is
57+
* deliberately framework-agnostic and does not depend on objectql. Two
58+
* byte-identical literals bound by convention were what #7572 removed — the
59+
* re-export keeps this package's public API unchanged while leaving exactly one
60+
* definition. ⛔ Do not restate the literal here; edit it in
61+
* `spec/src/data/secret-mask.ts`, where it is pinned.
5362
*/
54-
export const SECRET_MASK = '••••••••';
63+
export { SECRET_MASK } from '@objectstack/spec/data';
5564

5665
/** Wrap a `sys_secret` handle id as the opaque ref persisted on the row. */
5766
export function makeSecretRef(handleId: string): string {
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#7572] `SETTINGS_SECRET_MASK` is the ADR-0100 credential read mask under this
5+
* package's published name — the SAME declaration, not a copy of it.
6+
*
7+
* #7522 shipped it as a second byte-identical literal, bound to objectql's
8+
* `SECRET_MASK` by convention alone. Nothing compared them, and nothing could
9+
* have: each package asserted against its own constant, so an edit to either
10+
* literal would desynchronise the mask a console sees on the settings REST read
11+
* from the mask it sees on an encrypted FIELD read, with both suites still green.
12+
*
13+
* The hoist into `@objectstack/spec` removed the second declaration. This pin
14+
* holds what the hoist alone cannot: that this export still resolves to it. A
15+
* re-introduced local literal — the natural next edit for anyone who reads the
16+
* framework-agnostic note in the module header and stops there — passes every
17+
* behaviour test in `settings-routes.test.ts` (those compare a route response
18+
* against this very constant, so they move with it) and fails HERE the moment
19+
* its bytes differ.
20+
*
21+
* The literal is restated below for the same reason the spec-side pin restates
22+
* it: a check that imported the constant to describe the constant would be true
23+
* by construction. The bytes and the grep-findable spelling are pinned at the
24+
* declaration itself, in `spec/src/data/secret-mask.test.ts`.
25+
*/
26+
27+
import { describe, it, expect } from 'vitest';
28+
29+
import { SECRET_MASK } from '@objectstack/spec/data';
30+
31+
import { SETTINGS_SECRET_MASK, dropEchoedSecretMasks, redactSecretValues } from './settings-secret-redaction.js';
32+
33+
describe('SETTINGS_SECRET_MASK is the shared ADR-0100 mask (#7572)', () => {
34+
it('is the spec declaration, not a copy', () => {
35+
expect(SETTINGS_SECRET_MASK).toBe(SECRET_MASK);
36+
});
37+
38+
it('is the eight-bullet mask', () => {
39+
expect(SETTINGS_SECRET_MASK).toBe('••••••••');
40+
});
41+
42+
it('serves the shared mask on read — a client comparing against the FIELD mask recognises it', () => {
43+
const out = redactSecretValues(
44+
{ smtp_password: { value: 'hunter2', source: 'global', locked: false } },
45+
new Set(['smtp_password']),
46+
);
47+
expect(out.smtp_password.value).toBe(SECRET_MASK);
48+
});
49+
50+
it('reads an echo of the shared mask as "unchanged" — the write half of the same contract', () => {
51+
// What a console echoes back is whatever it was SERVED, which is the shared
52+
// constant; the drop has to key off that same string or the echo becomes a
53+
// real write of eight bullets over a live secret.
54+
const patch = dropEchoedSecretMasks({ smtp_password: SECRET_MASK }, new Set(['smtp_password']));
55+
expect(patch).toEqual({});
56+
});
57+
});

packages/services/service-settings/src/settings-secret-redaction.ts

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@
2020
* it is still an exposure, and the REST response is the one surface that should
2121
* never carry the cleartext.
2222
*
23-
* The mask shape is NOT invented here — it mirrors the encrypted-**field**
24-
* convention ADR-0100 pins for `secret` / `password` columns on the generic CRUD
25-
* path (`SECRET_MASK` in `@objectstack/objectql`, exercised by the
23+
* The mask shape is NOT invented here — it IS the encrypted-**field** convention
24+
* ADR-0100 pins for `secret` / `password` columns on the generic CRUD path
25+
* (`SECRET_MASK`, declared in `@objectstack/spec` and re-exported by
26+
* `@objectstack/objectql`, exercised by the
2627
* `records-forms.encrypted-field-behavior` checklist item):
2728
*
2829
* - **read**: a set value becomes the mask; an unset one stays `null`, so the
@@ -33,30 +34,36 @@
3334
* key is DROPPED from the patch, so a form round-trip that echoes the mask
3435
* does not overwrite the stored secret with the mask's literal text.
3536
*
36-
* The constant is redeclared rather than imported because this service is
37-
* deliberately framework-agnostic (see the `settings-service.ts` header): it
38-
* defines its own minimal `SettingsEngine` instead of importing `IDataEngine`,
39-
* and does not depend on `@objectstack/objectql` at all. Taking a runtime
40-
* dependency on the whole data engine to reach one string would undo that. The
41-
* long-term fix is to hoist the mask into a package both sides already depend on
42-
* (`@objectstack/spec`) and have objectql re-export it — recorded on #7522 as
43-
* follow-up rather than done here, since it is a cross-package move on a
44-
* security card.
37+
* The mask itself is now IMPORTED, not redeclared (#7572). #7522 shipped a
38+
* second byte-identical literal here and said why: this service is deliberately
39+
* framework-agnostic (see the `settings-service.ts` header) — it defines its own
40+
* minimal `SettingsEngine` instead of importing `IDataEngine`, and does not
41+
* depend on `@objectstack/objectql` at all, so taking a runtime dependency on
42+
* the whole data engine to reach one string was not worth it. That reasoning
43+
* held against depending on **objectql**, and it still does. It does not apply
44+
* to `@objectstack/spec`, which is where the mask now lives: spec is already a
45+
* dependency of this package, and already in its runtime graph (`manifest.ts`
46+
* → `@objectstack/platform-objects/system` → `@objectstack/spec/data`). The
47+
* framework-agnostic property is untouched — no objectql import was added here
48+
* or anywhere in this package.
4549
*/
4650

51+
import { SECRET_MASK } from '@objectstack/spec/data';
4752
import type { ResolvedSettingValue } from '@objectstack/spec/system';
4853

4954
/**
5055
* Value served in place of a set secret on the REST read path. Says "a secret
5156
* is set" without leaking its cleartext; an unset secret resolves to `null`
5257
* instead, so set-vs-unset stays observable.
5358
*
54-
* Byte-identical to `SECRET_MASK` in `@objectstack/objectql` (ADR-0100) — eight
55-
* U+2022 BULLET characters — so one client-side comparison recognises a masked
56-
* read from either surface. Spelled as the literal, not an escape, so a grep for
57-
* the mask finds both declarations.
59+
* The ADR-0100 credential read mask under the name this package publishes — the
60+
* SAME declaration objectql re-exports as `SECRET_MASK`, not a copy of it
61+
* (#7572), so one client-side comparison recognises a masked read from either
62+
* surface and no edit can leave the two disagreeing. The literal, its eight
63+
* U+2022 BULLET characters and its grep-findable spelling are pinned at the
64+
* declaration in `@objectstack/spec` (`data/secret-mask.ts`).
5865
*/
59-
export const SETTINGS_SECRET_MASK = '••••••••';
66+
export const SETTINGS_SECRET_MASK = SECRET_MASK;
6067

6168
/** Mask one resolved value: the effective value AND every cascade entry. */
6269
function maskResolved(resolved: ResolvedSettingValue): ResolvedSettingValue {

packages/spec/api-surface/data.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,7 @@
484484
"SEARCH_AUTO_EXCLUDED_FIELDS (const)",
485485
"SEARCH_AUTO_EXCLUDED_TYPES (const)",
486486
"SEARCH_VIRTUAL_TYPES (const)",
487+
"SECRET_MASK (const)",
487488
"SINGLE_OPTION_TYPES (const)",
488489
"SQLDialect (type)",
489490
"SQLDialectSchema (const)",

packages/spec/export-origins/data.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,7 @@
484484
"SEARCH_AUTO_EXCLUDED_FIELDS": "src/data/search-fields.ts#SEARCH_AUTO_EXCLUDED_FIELDS (const)",
485485
"SEARCH_AUTO_EXCLUDED_TYPES": "src/data/search-fields.ts#SEARCH_AUTO_EXCLUDED_TYPES (const)",
486486
"SEARCH_VIRTUAL_TYPES": "src/data/search-fields.ts#SEARCH_VIRTUAL_TYPES (const)",
487+
"SECRET_MASK": "src/data/secret-mask.ts#SECRET_MASK (const)",
487488
"SINGLE_OPTION_TYPES": "src/data/field-value.zod.ts#SINGLE_OPTION_TYPES (const)",
488489
"SQLDialect": "src/data/driver-sql.zod.ts#SQLDialect (type)",
489490
"SQLDialectSchema": "src/data/driver-sql.zod.ts#SQLDialectSchema (const)",

packages/spec/src/data/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ export * from './object.zod';
5454
// `enable.apiMethods` whitelist into its effective operation set (#3391).
5555
export * from './api-derivation';
5656
export * from './field.zod';
57+
// The credential read mask (ADR-0100) — the ONE string a masked read serves, in
58+
// place of the two byte-identical literals objectql and service-settings each
59+
// declared until #7572. Both now import this one, so the mask a console sees on
60+
// the encrypted-FIELD path cannot desynchronise from the mask it sees on the
61+
// settings REST path.
62+
export * from './secret-mask';
5763
// The unknown-authoring-key lint's CORE — comparator, finding shape, curated
5864
// guidance tables (#3786). Kept frontend-safe: the stack WALKER that imports
5965
// every schema lives in kernel/metadata-authoring-lint.ts, so this subpath's
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#7572] The credential read mask's two load-bearing properties, pinned where
5+
* the mask is now declared: its BYTES and its SPELLING.
6+
*
7+
* Hoisting the constant into `spec` removed the drift *between* the two former
8+
* copies — objectql's `SECRET_MASK` and service-settings' `SETTINGS_SECRET_MASK`
9+
* are the same declaration now, so they cannot desynchronise. What a single
10+
* declaration does NOT remove is an edit to that declaration, and this mask has
11+
* a wider blast radius than its one line suggests: a console renders
12+
* "configured vs not configured" from it, echoes it back unchanged on save, and
13+
* both write paths read that echo as "unchanged" (ADR-0100 §B3). Change the
14+
* bytes and every already-rendered form's echo stops being recognised — it
15+
* becomes a genuine write of eight bullets over a live credential.
16+
*
17+
* So this file keeps its OWN copy of the literal and compares. That restatement
18+
* is the point: a pin that imported the constant to check the constant would be
19+
* green by construction. The far-side pins in plugin-audit and driver-memory
20+
* restate it for the same reason and are deliberately left alone (#7572).
21+
*
22+
* The spelling half is not decoration either. #7572 records it as a property to
23+
* preserve: the mask is written as the literal — not `'•'.repeat(8)`, not
24+
* an escape — so that someone who greps for the eight bullets a client actually
25+
* received lands on the declaration. Nothing but a source read can hold that,
26+
* since every spelling produces the identical value.
27+
*/
28+
29+
import fs from 'node:fs';
30+
import path from 'node:path';
31+
import url from 'node:url';
32+
33+
import { describe, it, expect } from 'vitest';
34+
35+
import { SECRET_MASK } from './secret-mask';
36+
37+
const HERE = path.dirname(url.fileURLToPath(import.meta.url));
38+
const SOURCE = path.resolve(HERE, 'secret-mask.ts');
39+
40+
describe('SECRET_MASK — the credential read mask (ADR-0100 / #7572)', () => {
41+
it('is exactly eight U+2022 BULLET characters', () => {
42+
// Restated on purpose — see the module header.
43+
expect(SECRET_MASK).toBe('••••••••');
44+
expect([...SECRET_MASK]).toHaveLength(8);
45+
expect(new Set([...SECRET_MASK].map((c) => c.codePointAt(0)))).toEqual(new Set([0x2022]));
46+
// No surrogate pairs, so the UTF-16 length matches the code-point count —
47+
// the property every `value.length === 8` reader downstream relies on.
48+
expect(SECRET_MASK).toHaveLength(8);
49+
});
50+
51+
it('is spelled as the literal in source, so a grep for the mask finds it', () => {
52+
const source = fs.readFileSync(SOURCE, 'utf8');
53+
expect(source).toContain("export const SECRET_MASK = '••••••••';");
54+
});
55+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The credential read mask (ADR-0100) — the one string a client sees in place
5+
* of a value it is not allowed to read back.
6+
*
7+
* # Why this lives in `spec` and not next to either reader
8+
*
9+
* "A masked read looks like THIS" is a **client-facing contract**, and it is
10+
* consumed by two independent surfaces that never call each other:
11+
*
12+
* - the encrypted-**field** read path in `@objectstack/objectql` — `secret`
13+
* and generic `password` columns masked on `find`/`findOne`/`$expand`
14+
* (ADR-0100 §A/§B), re-exported from that package as `SECRET_MASK` for
15+
* hosts and privileged consumers;
16+
* - the settings **REST boundary** in `@objectstack/service-settings`, whose
17+
* `SETTINGS_SECRET_MASK` is this same constant under the name that package
18+
* publishes (#7522).
19+
*
20+
* Until #7572 each of those declared its own byte-identical literal, bound by
21+
* nothing but convention. That duplication is invisible while it holds and
22+
* silent when it breaks: an edit to either literal desynchronises the two
23+
* masked-read faces a console sees, and *both* packages' suites stay green,
24+
* because each asserts against its own copy. The console cannot be shown two
25+
* different masks — it renders "configured vs not configured" from this value
26+
* and echoes it back unchanged on save (the echoed-mask write guard, ADR-0100
27+
* §B3), so a drifted mask silently turns "unchanged" into "overwrite".
28+
*
29+
* `spec` is the contract face both sides already depend on, so the mask is
30+
* declared here **once** and imported. Neither reader takes a new dependency to
31+
* reach it: objectql is built on spec, and service-settings — which is
32+
* deliberately framework-agnostic and does NOT depend on objectql — already
33+
* loads `@objectstack/spec/data` at runtime through
34+
* `@objectstack/platform-objects`' object declarations.
35+
*
36+
* # The literal is spelled out on purpose
37+
*
38+
* Eight U+2022 BULLET characters, written as the literal rather than as a
39+
* `•` escape or a `.repeat(8)`, so that a plain grep for the mask a client
40+
* actually received finds this declaration. Pinned by `secret-mask.test.ts` in
41+
* both directions — the bytes AND the spelling — because "spelled so it can be
42+
* found" is a property no type can carry.
43+
*/
44+
45+
/**
46+
* Value served in place of a credential on a masked read: `secret` and generic
47+
* `password` fields on the engine's generic read path (ADR-0100), and secret-
48+
* backed settings on the settings REST read path (#7522).
49+
*
50+
* Says "a value is set" without leaking the plaintext or the storage handle. An
51+
* UNSET credential reads back `null` instead, never this mask — the masking is
52+
* presence-preserving on both surfaces, which is what lets a console render
53+
* "configured" vs "not configured" at all.
54+
*
55+
* Writing this exact string back is treated as "unchanged" and dropped by both
56+
* write paths, so a form round-trip that echoes what it read cannot overwrite
57+
* the stored credential with the mask's literal text. Accepted cost, recorded in
58+
* ADR-0100 §B3: eight bullets cannot itself be stored as a credential value
59+
* through an echoing client.
60+
*/
61+
export const SECRET_MASK = '••••••••';

0 commit comments

Comments
 (0)