Skip to content

Commit 7c41693

Browse files
Elon Muskclaude
andauthored
fix(core,plugin-auth,plugin-security): every OS_PLATFORM_OWNER_EMAIL reader asks the ONE list-aware parser (#13319)
* fix(core,plugin-auth,plugin-security): every OS_PLATFORM_OWNER_EMAIL reader asks the ONE list-aware parser The variable accepts one address or a comma-separated list (#11663 Choice 2B), but only the authorization derivation understood the list grammar. The other six readers held the operator's whole raw value as ONE address, so a configured list silently matched nobody: no promotion, no operator stamp, no Layer 0 wall bypass, and a boot diagnostic that printed the raw list where an address belongs. All fail-closed, all silent. All six now ask the shared parser in @objectstack/core. Adds isConfiguredPlatformAdminEmail (the membership half of matchesConfiguredPlatformAdmin, for readers holding a bare address) and PlatformAdminEmailConfig.declaredSpellings (the as-typed entries, so nothing splits the raw value a second time). A census pin enumerates the two remaining raw readers -- both grammar-independent truthiness checks -- and fails on a seventh. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WkdHQwHr2KQmaX7P1BHzi * fix(plugin-auth): seed the reader-census pin from __dirname, not import.meta This package is CJS-typed, so under module: NodeNext `import.meta` is TS1470 however well it runs under vitest. The package's own typecheck excludes **/*.test.ts and never saw it, but the test layer IS in front of tsc through the @objectstack/plugin-auth TEST_DEBT entry in check-type-check-coverage.mjs -- a shrink-only ratchet. The import.meta spelling pushed it 94 -> 95 and turned Type Check / debt ledger red. __dirname type-checks under the package's own config, is defined at runtime by vitest's transform, and is a spelling check:cross-package-test-inputs resolves statically -- which this file needs, since its walk of the sibling plugin-security tree is an escaping read that gate exists to see. Two sibling files in this package and one in plugin-security already record the same trap and the same remedy. No assertion changes: this is only how the test locates its own directory. Re-measured: 94 raw tsc errors, equal to the frozen ledger entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WkdHQwHr2KQmaX7P1BHzi --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ececf7a commit 7c41693

14 files changed

Lines changed: 837 additions & 92 deletions
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/core": patch
3+
"@objectstack/plugin-auth": patch
4+
"@objectstack/plugin-security": patch
5+
---
6+
7+
fix(core,plugin-auth,plugin-security): every `OS_PLATFORM_OWNER_EMAIL` reader asks the ONE list-aware parser (#13147)
8+
9+
`OS_PLATFORM_OWNER_EMAIL` accepts one address **or a comma-separated list** of
10+
them (#11663 Choice 2B). The list parse landed in a single home
11+
(`@objectstack/core`'s `platform-admin.ts`) and the authorization derivation
12+
consumed it — but every other reader kept calling `resolvePlatformOwnerEmail()`,
13+
which returns the operator's value trimmed and otherwise verbatim, and kept
14+
treating that whole string as ONE address.
15+
16+
An operator who configured a list therefore entered a self-contradictory state:
17+
authorization recognised them as a platform administrator, while four separate
18+
capabilities silently did nothing. Every direction failed **closed** — no
19+
privilege escalation existed at any point — but a declared capability vanished
20+
with no error anywhere:
21+
22+
- `bootstrap-platform-admin` promoted **nobody**, logging "will be promoted when
23+
that account registers" on every boot forever;
24+
- the walled operator stamp (`plugin-auth`) stamped **no** list member verified,
25+
so the account it should have provisioned was then refused elevation as
26+
`walled_owner_not_verified`;
27+
- `isVerifiedPlatformOwnerSession` / `platform-owner-wall-bypass` let **nobody**
28+
across the Layer 0 organization wall — the largest of the affected surfaces;
29+
- the walled boot diagnostic printed the raw list in the slot where an operator
30+
reads one address, and its dev-seed silence clause never matched.
31+
32+
All six readers now ask the same parser. `@objectstack/core` gains
33+
`isConfiguredPlatformAdminEmail(email, config)` — the membership half of
34+
`matchesConfiguredPlatformAdmin`, spelled once and shared, for the readers that
35+
hold a bare address rather than a `sys_user` row (the elevation gate keeps its
36+
two halves apart so `walled_owner_not_registered` and `walled_owner_not_verified`
37+
stay distinct answers; the stamp is handed an email before any row exists; the
38+
wall takes a fast negative before spending a row read). `PlatformAdminEmailConfig`
39+
gains `declaredSpellings`, the entries as the operator typed them, so the by-email
40+
`sys_user` lookup and the boot diagnostic get the as-typed form **from the one
41+
parse** instead of splitting the raw value a second time.
42+
43+
Behaviour for a single declared address is unchanged, including the
44+
case-insensitive match and the verbatim-spelling store lookup. A **refused**
45+
list (Choice 2B fails the whole variable closed on one unparseable entry) now
46+
reaches these readers as "zero administrators", which is the same answer they
47+
already gave for an unset variable — never a silently narrower set.
48+
49+
Two readers deliberately keep reading the raw value: the walled-boot refusal and
50+
the verification-path probe guard in `auth-plugin.ts` both use it as a pure
51+
truthiness test ("did the operator declare anything at all?"), which is
52+
grammar-independent. A census pin now enumerates the raw readers across both
53+
plugin packages and fails on a seventh.

packages/core/src/security/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ export {
175175
resolvePlatformAdminEmails,
176176
resetPlatformAdminEmailMemo,
177177
matchesConfiguredPlatformAdmin,
178+
isConfiguredPlatformAdminEmail,
178179
reportLegacyPlatformAdminGrant,
179180
resetLegacyPlatformAdminGrantReport,
180181
setPlatformAdminConfigSink,

packages/core/src/security/platform-admin.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
1515

1616
import {
17+
isConfiguredPlatformAdminEmail,
1718
matchesConfiguredPlatformAdmin,
1819
normalizePlatformAdminEmail,
1920
parsePlatformAdminEmails,
@@ -101,6 +102,31 @@ describe('[Choice 2B] parsePlatformAdminEmails', () => {
101102
}
102103
});
103104

105+
it('[#13147] declaredSpellings: the as-typed form of every entry, index-aligned with emails', () => {
106+
// The reason the field exists: two readers need the operator's own spelling
107+
// (the by-email `sys_user` lookup, whose driver `where` is an exact match,
108+
// and the boot diagnostic that quotes the addresses back) and NEITHER may
109+
// split `raw` a second time to get it.
110+
const parsed = parsePlatformAdminEmails(' Ops@Corp.example , second@corp.example ,OPS@corp.EXAMPLE ');
111+
expect(parsed.emails).toEqual(['ops@corp.example', 'second@corp.example']);
112+
expect(parsed.declaredSpellings).toEqual(['Ops@Corp.example', 'second@corp.example']);
113+
// Trimmed but NOT lowercased — byte-for-byte what `resolvePlatformOwnerEmail()`
114+
// used to hand a single-value reader, which is what makes this a
115+
// behaviour-preserving substitution at those call sites.
116+
expect(parsed.declaredSpellings[0]).toBe('Ops@Corp.example');
117+
// The duplicate collapsed on the NORMALIZED form, and the spelling that
118+
// survives is the one that won the position — so the arrays cannot drift.
119+
expect(parsed.declaredSpellings).toHaveLength(parsed.emails.length);
120+
});
121+
122+
it('[#13147] declaredSpellings is empty for every zero-administrator outcome', () => {
123+
for (const raw of [undefined, '', ' ', 'good@corp.example,nonsense']) {
124+
const parsed = parsePlatformAdminEmails(raw);
125+
expect(parsed.emails, `raw=${JSON.stringify(raw)}`).toEqual([]);
126+
expect(parsed.declaredSpellings, `raw=${JSON.stringify(raw)}`).toEqual([]);
127+
}
128+
});
129+
104130
it('a refused variable is reported as refused, not as unset', () => {
105131
// Both answer "zero config-derived administrators"; only one of them is an
106132
// operator mistake, and a caller must be able to tell them apart.
@@ -236,3 +262,50 @@ describe('[#11663 P5] reportLegacyPlatformAdminGrant', () => {
236262
expect(sink.warns[0]).toContain(`${ENV}=<the administrator's verified email address>`);
237263
});
238264
});
265+
266+
// ---------------------------------------------------------------------------
267+
describe('[#13147] isConfiguredPlatformAdminEmail — the ONE membership expression', () => {
268+
const LIST = parsePlatformAdminEmails('ops@corp.example, Second@Corp.Example');
269+
270+
it('answers for EVERY member of a comma-separated list, not just the first', () => {
271+
// The defect this predicate closes: the five single-value readers held the
272+
// operator's whole raw value as ONE address, so `'a@b.c,d@e.f'` could never
273+
// equal any single candidate and matched NOBODY.
274+
expect(isConfiguredPlatformAdminEmail('ops@corp.example', LIST)).toBe(true);
275+
expect(isConfiguredPlatformAdminEmail('second@corp.example', LIST)).toBe(true);
276+
expect(isConfiguredPlatformAdminEmail('stranger@corp.example', LIST)).toBe(false);
277+
// ⛔ And the raw list is not itself an address.
278+
expect(isConfiguredPlatformAdminEmail('ops@corp.example, Second@Corp.Example', LIST)).toBe(false);
279+
});
280+
281+
it('applies the ONE normalization to the candidate — trim AND lowercase', () => {
282+
expect(isConfiguredPlatformAdminEmail(' OPS@Corp.EXAMPLE ', LIST)).toBe(true);
283+
// The trim is the half a hand-rolled `.toLowerCase()` compare drops, which
284+
// is exactly how a seventh dialect would be born.
285+
expect(isConfiguredPlatformAdminEmail(' second@corp.example', LIST)).toBe(true);
286+
});
287+
288+
it('fail-closed on every other shape', () => {
289+
for (const empty of [
290+
parsePlatformAdminEmails(undefined),
291+
parsePlatformAdminEmails(' '),
292+
parsePlatformAdminEmails('ops@corp.example,nonsense'), // REFUSED
293+
]) {
294+
expect(isConfiguredPlatformAdminEmail('ops@corp.example', empty)).toBe(false);
295+
}
296+
for (const candidate of [undefined, null, '', ' ', 42, {}]) {
297+
expect(isConfiguredPlatformAdminEmail(candidate, LIST), String(candidate)).toBe(false);
298+
}
299+
});
300+
301+
it('is the membership half of matchesConfiguredPlatformAdmin — one expression, not two', () => {
302+
// Same list, same address: the row predicate adds the verified check and
303+
// nothing else. If these two ever disagree about membership, the config
304+
// anchor and the plugin readers have split again.
305+
expect(matchesConfiguredPlatformAdmin({ email: 'second@corp.example', email_verified: true }, LIST)).toBe(true);
306+
expect(isConfiguredPlatformAdminEmail('second@corp.example', LIST)).toBe(true);
307+
// Verified is the ONLY difference.
308+
expect(matchesConfiguredPlatformAdmin({ email: 'second@corp.example' }, LIST)).toBe(false);
309+
expect(isConfiguredPlatformAdminEmail('second@corp.example', LIST)).toBe(true);
310+
});
311+
});

packages/core/src/security/platform-admin.ts

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,20 @@ export interface PlatformAdminEmailConfig {
107107
* they are told apart by {@link refusal} rather than by a second empty value.
108108
*/
109109
readonly emails: readonly string[];
110+
/**
111+
* The SAME administrators as {@link emails} and index-aligned with it, each
112+
* spelled as the operator typed it — trimmed only, never lowercased (exactly
113+
* what `resolvePlatformOwnerEmail()` used to hand a single-value reader).
114+
*
115+
* It exists so that no consumer ever has a reason to split {@link raw} a
116+
* second time. Two readers need the as-typed form and neither may re-parse
117+
* to get it: the elevation gate's by-email `sys_user` lookup queries the
118+
* verbatim spelling alongside the normalized one (an imported/legacy row may
119+
* not be stored lowercased, and a driver `where` is an exact match), and the
120+
* walled boot diagnostic quotes the addresses back to the operator, who
121+
* should see what they wrote.
122+
*/
123+
readonly declaredSpellings: readonly string[];
110124
/** What the operator actually typed, when the variable was set to anything. */
111125
readonly raw?: string;
112126
/**
@@ -117,7 +131,10 @@ export interface PlatformAdminEmailConfig {
117131
readonly refusal?: string;
118132
}
119133

120-
const EMPTY_CONFIG: PlatformAdminEmailConfig = Object.freeze({ emails: Object.freeze([]) as readonly string[] });
134+
const EMPTY_CONFIG: PlatformAdminEmailConfig = Object.freeze({
135+
emails: Object.freeze([]) as readonly string[],
136+
declaredSpellings: Object.freeze([]) as readonly string[],
137+
});
121138

122139
/**
123140
* Parse one raw `OS_PLATFORM_OWNER_EMAIL` value into the administrator list.
@@ -132,6 +149,7 @@ export function parsePlatformAdminEmails(raw: string | undefined): PlatformAdmin
132149
if (text.trim() === '') return EMPTY_CONFIG;
133150

134151
const emails: string[] = [];
152+
const declaredSpellings: string[] = [];
135153
for (const piece of text.split(PLATFORM_ADMIN_EMAIL_SEPARATOR)) {
136154
const entry = normalizePlatformAdminEmail(piece);
137155
// Blanks are DROPPED, not refused: a trailing separator or a line wrapped
@@ -141,6 +159,7 @@ export function parsePlatformAdminEmails(raw: string | undefined): PlatformAdmin
141159
if (!isParseableAddress(entry)) {
142160
return {
143161
emails: Object.freeze([]) as readonly string[],
162+
declaredSpellings: Object.freeze([]) as readonly string[],
144163
raw: text,
145164
refusal:
146165
`${PLATFORM_OWNER_EMAIL_ENV} entry ${JSON.stringify(piece)} is not an email address, so the `
@@ -151,11 +170,19 @@ export function parsePlatformAdminEmails(raw: string | undefined): PlatformAdmin
151170
+ 'comma-separated list of them.',
152171
};
153172
}
154-
// Duplicates collapse; first declaration wins the position.
155-
if (!emails.includes(entry)) emails.push(entry);
173+
// Duplicates collapse; first declaration wins the position — and the
174+
// spelling that wins it is the one kept, so the two arrays stay aligned.
175+
if (!emails.includes(entry)) {
176+
emails.push(entry);
177+
declaredSpellings.push(piece.trim());
178+
}
156179
}
157180

158-
return { emails: Object.freeze(emails) as readonly string[], raw: text };
181+
return {
182+
emails: Object.freeze(emails) as readonly string[],
183+
declaredSpellings: Object.freeze(declaredSpellings) as readonly string[],
184+
raw: text,
185+
};
159186
}
160187

161188
/**
@@ -246,11 +273,49 @@ export function matchesConfiguredPlatformAdmin(
246273
): boolean {
247274
if (config.emails.length === 0) return false;
248275
if (!row || typeof row !== 'object') return false;
249-
const email = normalizePlatformAdminEmail((row as { email?: unknown }).email);
250-
if (email === '' || !config.emails.includes(email)) return false;
276+
if (!isConfiguredPlatformAdminEmail((row as { email?: unknown }).email, config)) return false;
251277
return isEmailVerifiedUserRow(row);
252278
}
253279

280+
/**
281+
* [#13147] Is this bare ADDRESS one of the declared administrators?
282+
*
283+
* The membership half of {@link matchesConfiguredPlatformAdmin}, spelled once
284+
* and exported, because the row-and-verified predicate above is not the shape
285+
* every reader of `OS_PLATFORM_OWNER_EMAIL` needs:
286+
*
287+
* - the elevation gate (`plugin-security/bootstrap-platform-admin.ts`) must
288+
* keep the two halves SEPARATE — its `walled_owner_not_registered` and
289+
* `walled_owner_not_verified` diagnostics are different answers;
290+
* - the creation-time operator stamp (`plugin-auth`) is handed an email
291+
* STRING by better-auth, before any row exists to read;
292+
* - the Layer 0 wall bypass takes a fast negative on the session's
293+
* server-resolved email before it spends a `sys_user` read.
294+
*
295+
* ⛔ Those readers must NOT hand-roll `config.emails.includes(x.toLowerCase())`
296+
* instead. That expression is where a seventh dialect gets born: it silently
297+
* drops the trim, and a stray space in one list entry then makes an
298+
* administrator vanish with nothing to notice. One membership expression, one
299+
* normalization ({@link normalizePlatformAdminEmail}), one place to fix.
300+
*
301+
* Fail-closed like everything else here: an empty or refused config answers
302+
* `false` without looking at the candidate, and a blank/non-string candidate
303+
* answers `false` against any config.
304+
*
305+
* ⚠️ This is a match against CONFIGURATION only — it says nothing about whether
306+
* the address is verified, or whether the caller actually holds it. Standing
307+
* still requires {@link matchesConfiguredPlatformAdmin} over the caller's own
308+
* stored row; see this module's header for why `grants.email` is never it.
309+
*/
310+
export function isConfiguredPlatformAdminEmail(
311+
email: unknown,
312+
config: PlatformAdminEmailConfig,
313+
): boolean {
314+
if (config.emails.length === 0) return false;
315+
const candidate = normalizePlatformAdminEmail(email);
316+
return candidate !== '' && config.emails.includes(candidate);
317+
}
318+
254319
/**
255320
* [#11663 P5] The migration pointer for the LEGACY anchor.
256321
*

packages/plugins/plugin-auth/src/auth-plugin-walled-owner-verification-path.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,54 @@ describe('#11640 — the dead-end shape warns, by name and with the remedy', ()
136136
});
137137
});
138138

139+
// ---------------------------------------------------------------------------
140+
describe('[#13147] the boot diagnostic under a comma-separated OS_PLATFORM_OWNER_EMAIL', () => {
141+
const SECOND_OWNER = 'ops@corp.example';
142+
const OWNER_LIST = `${OWNER}, ${SECOND_OWNER}`;
143+
144+
it('NAMES each declared administrator instead of printing the raw list in an address slot', () => {
145+
// The card's fourth reader. It does not COMPARE the value, it PRINTS it —
146+
// so "correct" here is not the comparators' fix pattern: the line must name
147+
// the declared set, each member as the operator typed it, in a slot an
148+
// operator reads as addresses.
149+
walledWithDeclaredOwner('isolated', OWNER_LIST);
150+
const msg = resolveWalledOwnerVerificationPathWarning(NOTHING_WIRED)!;
151+
expect(msg).toBeTruthy();
152+
expect(msg).toContain('OS_PLATFORM_OWNER_EMAIL');
153+
expect(msg).toContain(OWNER);
154+
expect(msg).toContain(SECOND_OWNER);
155+
// Each member named separately — ⛔ not the raw string with its separator
156+
// swallowed into one address-looking token.
157+
expect(msg).toContain(`OS_PLATFORM_OWNER_EMAIL=${OWNER}, ${SECOND_OWNER}`);
158+
expect(msg).toContain(WALLED_OWNER_NO_VERIFICATION_PATH);
159+
});
160+
161+
it('prints the entries as TYPED, and only the entries the parse kept', () => {
162+
// Trailing separators and blank entries are dropped by the parse, so what
163+
// is printed is exactly the set that was understood — an operator comparing
164+
// this line to their config can SEE an entry that did not survive.
165+
walledWithDeclaredOwner('isolated', ` Ops.Lead@Corp.EXAMPLE , ${SECOND_OWNER} ,`);
166+
const msg = resolveWalledOwnerVerificationPathWarning(NOTHING_WIRED)!;
167+
expect(msg).toContain(`OS_PLATFORM_OWNER_EMAIL=Ops.Lead@Corp.EXAMPLE, ${SECOND_OWNER}`);
168+
});
169+
170+
it('the dev-seed silence clause matches ANY declared member, not just the first', () => {
171+
// `seedStampsDeclaredOwner`: the seed rescues the fresh-store shape when it
172+
// provisions a declared administrator. Under a list that used to compare
173+
// the seed address against the whole raw value and never match, so a dev
174+
// boot warned about a dead end the seed had already closed.
175+
process.env.NODE_ENV = 'development';
176+
process.env.OS_SEED_ADMIN = '1';
177+
walledWithDeclaredOwner('isolated', `${OWNER}, ${DEV_SEED_ADMIN}`);
178+
expect(resolveWalledOwnerVerificationPathWarning(nothingWired('no-human-users'))).toBeNull();
179+
});
180+
181+
it('⛔ a REFUSED list declares nobody, so the diagnostic stays silent like an unset variable', () => {
182+
walledWithDeclaredOwner('isolated', `${OWNER},not-an-email`);
183+
expect(resolveWalledOwnerVerificationPathWarning(NOTHING_WIRED)).toBeNull();
184+
});
185+
});
186+
139187
describe('#11640 — controls: every neighbouring shape stays SILENT', () => {
140188
it('an email transport is wired ⇒ the verification link can be delivered ⇒ no warning', () => {
141189
walledWithDeclaredOwner();

0 commit comments

Comments
 (0)