Skip to content

Commit f5bb94f

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-27801-chunked-init-filename-min1
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
2 parents a739f74 + 755e285 commit f5bb94f

15 files changed

Lines changed: 451 additions & 16 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/lint': minor
3+
'@objectstack/spec': patch
4+
'@objectstack/objectql': patch
5+
---
6+
7+
feat(lint)!: `searchable-field-unsearchable` reports a `searchableFields` entry that names a `secret` or `password` field, which the engine leaves out of every search without a word
8+
9+
Clause-②: no (narrowing) — `os validate`, `os build`, `os lint` and the metadata publish door refuse a `searchableFields` entry whose field type is `secret` or `password`, on the object's own set, on every list view surface and on a react page's ListView `searchableFields` prop, which they accepted before. The finding takes the severity of the `formula`-entry finding (`error`). No rule id is added or removed.
10+
Metadata-protocol: narrowed
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: a searchableFields entry the engine already withheld from every search (ADR-0100) is now refused where it is authored. The one remedy is to delete the entry, which a conversion cannot decide for the author, so there is no key for a migration to rewrite and no tombstone. -->
13+
14+
**BREAKING** accept-set narrowing at every authoring door for `searchableFields`.
15+
16+
- **Before.** The engine's search expansion leaves every credential column (`secret`, `password`) out of the set it scans (ADR-0100), an author-declared one included, and says nothing. A declaration naming one passed every gate and searched a narrower set than written; a declaration naming only credential fields fell through to the auto-default set.
17+
- **Now.** The rule refuses the entry at `error`, naming the entry, its type and the object. It is judged on every object, a better-auth `password` included: the read mask spares that one, the search withhold does not.
18+
- **Who sees it.** An object or list view whose `searchableFields` names a `secret` or `password` field. No metadata shipped in this repository does.
19+
20+
**The remedy.** Drop the entry: credential fields are never searched. `os explain searchable-field-unsearchable` states what the engine does with it on each surface.
21+
22+
`@objectstack/spec` exports `isCredentialFieldType(fieldType)` from `@objectstack/spec/data`: whether a field type is masked on read on at least one object (`secret`, `password`), the exemption-free question. `@objectstack/objectql`'s `collectCredentialFields` now reads it instead of its own type literal; its answer is unchanged.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/components': patch
3+
---
4+
5+
fix(components): a grid's select-all draws a dash for a partial selection
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
With some but not all rows of the page selected, the data table's header checkbox was already announced as mixed (`aria-checked="mixed"`) but drew the same check mark as "all selected", so a user could read two selected rows as the whole page. It now draws a dash, filled like the checked state; all selected still draws the check and none selected draws nothing. The upstream Shadcn `Checkbox` is unchanged: the header uses a package-internal tri-state wrapper.

‎packages/lint/src/rule-explanations.ts‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2465,11 +2465,24 @@ const SEARCHABLE_FIELD_UNSEARCHABLE_EXPLANATION: RuleExplanation = {
24652465
VIRTUAL_FIELD_STORAGE,
24662466
'A formula entry is refused on EVERY surface, the object\'s own set included — the REST ingress ' +
24672467
'answers `400 INVALID_FIELD`, the same answer a stale entry gets: measured, it ' +
2468-
'matched 0 rows on driver-memory, and 0 rows with no error on driver-sql. Otherwise the ' +
2469-
'object\'s own `searchableFields` is existence-only: its declared branch scans any stored ' +
2470-
'column, so a json or lookup column declared there is a narrow choice the engine executes, not ' +
2471-
'a finding. A system column outside the allowed set is not judged either: its runtime ' +
2472-
'metadata is registry-owned and invisible here.',
2468+
'matched 0 rows on driver-memory, and 0 rows with no error on driver-sql. Otherwise, apart ' +
2469+
'from a credential entry, the object\'s own `searchableFields` is existence-only: its declared ' +
2470+
'branch scans any stored column, so a json or lookup column declared there is a narrow choice ' +
2471+
'the engine executes, not a finding. A system column outside the allowed set is not judged ' +
2472+
'either: its runtime metadata is registry-owned and invisible here.',
2473+
'A `secret` or `password` entry is refused on EVERY surface as well, the object\'s own set ' +
2474+
'included. The engine leaves every credential column out of the set a search scans (ADR-0100), ' +
2475+
'an author-declared one included, and says nothing: a declaration naming one scans a narrower ' +
2476+
'set than written, and one naming only credential fields falls through to the AUTO-DEFAULT ' +
2477+
'set. A list view naming one is refused with `400 INVALID_FIELD` when the object\'s set is the ' +
2478+
'auto-default, which never admits a credential type; when the object declares the entry, the ' +
2479+
'REST ingress admits it and the engine withholds it, so a view naming only credential fields ' +
2480+
'searches the object\'s whole set. Credential types are judged by the spec\'s ' +
2481+
'`isCredentialFieldType`, the predicate the engine\'s credential collector reads, with no ' +
2482+
'better-auth exemption: the read mask spares a better-auth `password`, the search withhold ' +
2483+
'does not. An error, as a formula entry is: the entry can never match, and the narrowing is ' +
2484+
'otherwise silent. The one fix is to drop it — mirroring the value onto a searchable column ' +
2485+
'would publish what the withhold protects.',
24732486
],
24742487
};
24752488

‎packages/lint/src/runtime-gate.object-writes.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,22 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', ()
301301
expect(f.message).toContain('gone_field');
302302
});
303303

304+
it('REFUSES an object whose searchableFields names a credential field the engine never searches', () => {
305+
// The same rule's credential arm, at the publish door: the engine withholds
306+
// a `secret` / `password` column from every search (ADR-0100), so the
307+
// declaration is refused where a Studio tenant writes it.
308+
const result = expectSingleRefusal(
309+
cleanObject({
310+
fields: { owner: { type: 'text' }, api_key: { type: 'secret' } },
311+
searchableFields: ['owner', 'api_key'],
312+
}),
313+
'searchable-field-unsearchable',
314+
);
315+
const f = result.errors.find((e) => e.rule === 'searchable-field-unsearchable')!;
316+
expect(f.path).toBe('objects.leave_request.searchableFields[1]');
317+
expect(f.message).toContain("is a 'secret' field, a credential");
318+
});
319+
304320
it('REFUSES an object whose built-in list view names a column it does not have', () => {
305321
const result = expectSingleRefusal(
306322
cleanObject({ listViews: { all: { label: 'All', columns: ['owner', 'gone_column'] } } }),

‎packages/lint/src/validate-react-page-props.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,24 @@ describe('validateReactPageProps — <ListView> searchableFields (#4329)', () =>
520520
expect(f[0].message).toContain('400 INVALID_FIELD');
521521
});
522522

523+
it('flags a secret or password entry under the metadata verdict — the shared core\'s credential check', () => {
524+
const vault = {
525+
name: 'crm_account',
526+
fields: { name: { type: 'text' }, api_key: { type: 'secret' }, portal_password: { type: 'password' } },
527+
};
528+
const f = pastDeprecation(validateReactPageProps(
529+
listPage(
530+
list(`data={{ provider: 'object', object: 'crm_account' }} searchableFields={['name', 'api_key', 'portal_password']}`),
531+
[vault],
532+
),
533+
));
534+
expect(f.map((x) => [x.rule, x.severity, x.path])).toEqual([
535+
[SEARCHABLE_FIELD_UNSEARCHABLE, 'error', 'pages[0].source › searchableFields[1]'],
536+
[SEARCHABLE_FIELD_UNSEARCHABLE, 'error', 'pages[0].source › searchableFields[2]'],
537+
]);
538+
expect(f[0].hint).toBe('Drop "api_key": credential fields are never searched');
539+
});
540+
523541
it('[#8404] warns on an unprovisioned anchor, and PINS that this surface threads the index', () => {
524542
// Load-bearing beyond the warning itself. `validateReactPageProps` builds
525543
// `unprovisionedAnchors` for its other field-prop checks, so if this

‎packages/lint/src/validate-searchable-fields.test.ts‎

Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -939,6 +939,127 @@ describe('[#6674] validateSearchableFields — a virtual formula entry', () => {
939939
});
940940
});
941941

942+
/**
943+
* A credential entry. Since the engine's ADR-0100 search withhold, the search
944+
* expansion leaves every `secret` / `password` column out of the field map it
945+
* resolves over, an author-declared one included, and says nothing — so the
946+
* declared set is silently narrower than written. The finding is that
947+
* withhold's build-time voice, at the virtual entry's severity.
948+
*/
949+
describe('validateSearchableFields — a secret or password entry', () => {
950+
const accountFields = {
951+
name: { type: 'text' },
952+
billing_email: { type: 'email' },
953+
api_key: { type: 'secret' },
954+
portal_password: { type: 'password' },
955+
};
956+
957+
it.each([
958+
['secret', 'api_key'],
959+
['password', 'portal_password'],
960+
])("flags a '%s' entry on the OBJECT's own searchableFields — previously clean", (ctype, field) => {
961+
const findings = validateSearchableFields({
962+
objects: [
963+
{ name: 'crm_account', fields: accountFields, searchableFields: ['name', field, 'billing_email'] },
964+
],
965+
});
966+
967+
// The text and email entries beside it stay clean: the check reads the TYPE.
968+
expect(findings).toHaveLength(1);
969+
expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE);
970+
expect(findings[0].severity).toBe('error');
971+
expect(findings[0].path).toBe('objects[0].searchableFields[1]');
972+
expect(findings[0].where).toBe('object "crm_account"');
973+
expect(findings[0].message).toContain(`"${field}"`);
974+
expect(findings[0].message).toContain(`is a '${ctype}' field, a credential`);
975+
expect(findings[0].hint).toBe(`Drop "${field}": credential fields are never searched`);
976+
});
977+
978+
it.each([
979+
['secret', 'api_key'],
980+
['password', 'portal_password'],
981+
])("flags a '%s' entry on a list view narrowing, under the same verdict", (ctype, field) => {
982+
const findings = validateSearchableFields({
983+
objects: [
984+
{
985+
name: 'crm_account',
986+
fields: accountFields,
987+
listViews: { all: { type: 'grid', searchableFields: ['name', field] } },
988+
},
989+
],
990+
});
991+
992+
// Auto-default object: without the credential check the admissibility pass
993+
// would explain this entry by its type alone and offer a text-like field.
994+
expect(findings).toHaveLength(1);
995+
expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE);
996+
expect(findings[0].severity).toBe('error');
997+
expect(findings[0].path).toBe('objects[0].listViews.all.searchableFields[1]');
998+
expect(findings[0].message).toContain(`is a '${ctype}' field, a credential`);
999+
expect(findings[0].hint).toBe(`Drop "${field}": credential fields are never searched`);
1000+
});
1001+
1002+
it('flags a view entry the object ITSELF declares — the declared set does not make it searchable', () => {
1003+
// The object's declaration admits the entry at the REST ingress, so the
1004+
// #4830 admissibility pass passes it; the engine still withholds it.
1005+
const findings = validateSearchableFields({
1006+
objects: [
1007+
{
1008+
name: 'crm_account',
1009+
fields: accountFields,
1010+
searchableFields: ['name', 'api_key'],
1011+
listViews: { all: { type: 'grid', searchableFields: ['api_key'] } },
1012+
},
1013+
],
1014+
});
1015+
1016+
expect(findings.map((f) => [f.rule, f.path])).toEqual([
1017+
[SEARCHABLE_FIELD_UNSEARCHABLE, 'objects[0].searchableFields[1]'],
1018+
[SEARCHABLE_FIELD_UNSEARCHABLE, 'objects[0].listViews.all.searchableFields[0]'],
1019+
]);
1020+
});
1021+
1022+
it('is exemption-free: a `password` on a better-auth object is reported too', () => {
1023+
// The read mask spares a better-auth `password`; the search withhold does not.
1024+
const findings = validateSearchableFields({
1025+
objects: [
1026+
{
1027+
name: 'crm_account',
1028+
managedBy: 'better-auth',
1029+
fields: accountFields,
1030+
searchableFields: ['name', 'portal_password'],
1031+
},
1032+
],
1033+
});
1034+
1035+
expect(findings).toHaveLength(1);
1036+
expect(findings[0].path).toBe('objects[0].searchableFields[1]');
1037+
expect(findings[0].message).toContain("is a 'password' field, a credential");
1038+
});
1039+
1040+
it('CONTROL — the same declaration with text and email entries only stays clean', () => {
1041+
expect(
1042+
validateSearchableFields({
1043+
objects: [
1044+
{
1045+
name: 'crm_account',
1046+
fields: accountFields,
1047+
searchableFields: ['name', 'billing_email'],
1048+
listViews: { all: { type: 'grid', searchableFields: ['name', 'billing_email'] } },
1049+
},
1050+
],
1051+
}),
1052+
).toEqual([]);
1053+
});
1054+
1055+
it('`os explain` names the silent withhold and the exemption-free predicate', () => {
1056+
const text = explanationOf(SEARCHABLE_FIELD_UNSEARCHABLE);
1057+
expect(text).toContain('A `secret` or `password` entry is refused on EVERY surface');
1058+
expect(text).toContain('`isCredentialFieldType`');
1059+
expect(text).toContain('no better-auth exemption');
1060+
});
1061+
});
1062+
9421063
/**
9431064
* [#8404] The FIFTH blanket-`SYSTEM_FIELDS` read site. Existence and provenance
9441065
* are different questions: on an ADR-0015 `external` object the platform
@@ -1132,13 +1253,14 @@ describe('[#22161] one-line verdicts — the three searchable ids', () => {
11321253
describe('[#22161] one-line fixes — the three searchable ids', () => {
11331254
it('every `fix:` line the cases above fired is one line of at most 200 characters', () => {
11341255
// Coverage control: every arm fired — both unknown arms, the anchor, and
1135-
// the four unsearchable arms (virtual, outside a declared set, a reference
1136-
// column and any other type outside the auto-default set).
1256+
// the five unsearchable arms (virtual, credential, outside a declared set,
1257+
// a reference column and any other type outside the auto-default set).
11371258
const hints = fired.map((f) => f.hint);
11381259
expect(hints.some((h) => h.startsWith('Fix the name, or add "')), 'unknown name').toBe(true);
11391260
expect(hints.some((h) => h.startsWith("'search' scans this object's own columns only")), 'dotted').toBe(true);
11401261
expect(hints.some((h) => h.startsWith("If the remote table carries '")), 'anchor').toBe(true);
11411262
expect(hints.some((h) => h.startsWith('Mirror the computed value')), 'virtual').toBe(true);
1263+
expect(hints.some((h) => h.endsWith(': credential fields are never searched')), 'credential').toBe(true);
11421264
expect(hints.some((h) => h.startsWith('Add "') && h.includes('.searchableFields')), 'declared set').toBe(true);
11431265
expect(hints.some((h) => h.includes("to search by the related record's title")), 'reference').toBe(true);
11441266
expect(hints.some((h) => h.endsWith('or target a text-like field instead')), 'other type').toBe(true);

‎packages/lint/src/validate-searchable-fields.ts‎

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,30 @@
6868
* WITH NO ERROR on driver-sql. Since #6674 the spec resolution drops such
6969
* an entry and both enforcement faces refuse it by name.
7070
*
71+
* 4. CREDENTIALS, on EVERY surface — the object's own set included
72+
* (`searchable-field-unsearchable`): a `secret` or `password` entry names a
73+
* real, stored field, so checks 1 and 3 pass it — but since the engine's
74+
* ADR-0100 search withhold the expansion leaves every credential column out
75+
* of the field map it resolves over (`expandSearchOnAst`,
76+
* `@objectstack/objectql`, by `collectCredentialFields`), an author-declared
77+
* one included, and says nothing. The declared set is silently narrower
78+
* than written, and a declaration naming ONLY credential fields falls
79+
* through to the auto-default set — #4254's "asked narrower, answered
80+
* wider" again. Judged by the spec's own predicate
81+
* ({@link isCredentialFieldType}, `@objectstack/spec/data`), the one that
82+
* engine collector reads, so linter and engine cannot disagree about which
83+
* types are credentials. The withhold is exemption-free (a better-auth
84+
* `password` is withheld too), and so is this check.
85+
*
86+
* `error`, the virtual entry's severity, deliberately and for the virtual
87+
* entry's reason: the entry can never match wherever it is declared, and a
88+
* narrowing the runtime makes in silence gets its word at build time. A
89+
* warning would leave `os validate` passing a declaration the runtime does
90+
* not honour, which is the gap this check exists to close. The remedy has
91+
* one form — drop the entry — since no mirror of a credential onto a
92+
* searchable column is a fix: that would publish the very value the
93+
* withhold protects.
94+
*
7195
* The OBJECT's own `searchableFields` stays existence-only OTHERWISE, and the
7296
* dividing line is STORAGE, not search quality: the runtime's declared branch
7397
* filters by existence and scannability, never by type taste, so a json or
@@ -121,6 +145,7 @@
121145

122146
import {
123147
resolveSearchFieldResolution,
148+
isCredentialFieldType,
124149
isVirtualSearchField,
125150
SEARCH_AUTO_EXCLUDED_FIELDS,
126151
type SearchFieldMeta,
@@ -159,8 +184,9 @@ export interface SearchableFieldFinding {
159184
*
160185
* - `'canonical'` — the object's own `searchableFields`. The runtime honors
161186
* any entry that exists and has a stored column to scan (existence- and
162-
* scannability-filtered, never type-filtered), so existence and virtuality
163-
* are checked and nothing else (#6674).
187+
* scannability-filtered, never type-filtered), apart from the credential
188+
* columns the engine withholds, so existence, virtuality and credentials
189+
* are checked and nothing else (#6674, module note check 4).
164190
* - `'narrowing'` — a list view's `searchableFields` (metadata or react
165191
* surface). Clients echo it as the `$searchFields` override, which the
166192
* #4254 ingress gate intersects with the object's allowed set — entries the
@@ -430,6 +456,32 @@ export function checkSearchableFieldList(
430456
continue;
431457
}
432458

459+
// ── Credential entries — EVERY surface, canonical included ──
460+
//
461+
// The engine's search expansion withholds every `secret` / `password`
462+
// column from the set it scans (ADR-0100), a declared one included, without
463+
// a word; this is that withhold's build-time voice (module note, check 4).
464+
// Judged by the spec predicate the engine's collector reads. Ahead of the
465+
// admissibility pass on purpose: on a view over an auto-default object
466+
// that pass would otherwise explain the entry by its type alone, and the
467+
// remedy it offers ("target a text-like field") is not this one.
468+
const ctype = target.fields[name]?.type;
469+
if (isCredentialFieldType(ctype)) {
470+
findings.push({
471+
severity: 'error',
472+
rule: SEARCHABLE_FIELD_UNSEARCHABLE,
473+
where,
474+
path: `${path}[${i}]`,
475+
message:
476+
`${subject} entry "${name}" on object "${objectName}" is a '${ctype}' field, a ` +
477+
`credential: 'search' never scans it`,
478+
// One remedy line; what the engine does with the entry on each surface
479+
// is the id's `os explain` entry.
480+
hint: `Drop "${name}": credential fields are never searched`,
481+
});
482+
continue;
483+
}
484+
433485
// ── Runtime admissibility (#4830) — view-level narrowings only ──
434486
if (!resolution || resolution.allowed.has(name)) continue;
435487
// ③ System column outside the allowed set: its runtime metadata is

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
* ruling of 2026-08-12 on #7728.
3838
*/
3939

40-
import { isMaskedOnReadFieldType, type ServiceObject } from '@objectstack/spec/data';
40+
import { isCredentialFieldType, isMaskedOnReadFieldType, type ServiceObject } from '@objectstack/spec/data';
4141

4242
/**
4343
* Prefix marking a persisted field value as a `sys_secret` handle ref rather
@@ -291,7 +291,7 @@ export function collectCredentialFields(schema: ServiceObject | undefined | null
291291
if (!fields) return [];
292292
const out: string[] = [];
293293
for (const [name, def] of Object.entries(fields)) {
294-
if (def && (def.type === 'secret' || def.type === 'password')) out.push(name);
294+
if (def && isCredentialFieldType(def.type)) out.push(name);
295295
}
296296
return out;
297297
}

0 commit comments

Comments
 (0)