Skip to content

Commit 8e500f2

Browse files
os-warrenclaude
andauthored
fix(plugin-auth): name a remedy that works in the no-sign-in boot report (#15720)
* wip(plugin-auth): #15588 remedy text + behavioural pins Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * fix(plugin-auth): name a remedy that works in the no-sign-in boot report The `no_sign_in_account_at_boot` report fires on the deployment nobody can sign in to: human `sys_user` rows, zero `sys_account` rows. It ended with two remedies, and measured on the exact population it fires on, neither did what its sentence said. "Open the audience posture so an existing person can register their own login" produced no login: every posture other than `invite_only` forces `requireEmailVerification` on, so a login registered that way is refused `EMAIL_NOT_VERIFIED` at first sign-in, and a locked-out self-hosted install is usually the shape with no mail transport wired. "Write a `sys_account` credential row directly against the store" was worse than useless. The `password` column carries a secret in the platform's own hash format, so a plaintext one authenticates nothing — and `probeSignInAccountsPresence` asks only whether ANY `sys_account` row exists, so writing one turns this very report off. The operator's first attempt at the named remedy turned the loud dead end back into the silent one the report was written to end. The line now names the path measured to work: one pending `sys_invitation` row written directly against the store, then an ordinary sign-up. The invitation carve-out admits that one creation under every posture, so no door is widened and no mail transport is needed. The other two are still named, as the two things that look like remedies and are not. Message text only — no admission semantics move, exactly as the diagnostic's own card scoped itself. The probe is untouched; tightening it would move that card's diagnostic semantics and is deliberately not done here. Each clause is pinned against the behaviour it describes rather than string- matched: the invitation carve-out is driven across the whole `AUDIENCE_POSTURES` vocabulary with an un-invited control, the self-silencing claim is driven through the real probe with a hand-written plaintext credential row, and the forced-verification claim is read off `getPublicConfig()`'s wiring mirror with an `invite_only` control. Every new pin was mutation-checked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * fix(plugin-auth): scope the no-mail-transport rider on the invitation remedy Clause-② review F1: the rider was FALSE on a reachable population. The invitation carve-out is an ADMISSION verdict, not a verification bypass, so the forced-verification mechanism this same line warns about for remedy (b) applies to the INVITED login too. Measured by the reviewer on `open` and on `email_domain` with a pending invitation: sign-up 200 with emailVerified false, sign-in 403 EMAIL_NOT_VERIFIED — and the created row makes probeSignInAccountsPresence answer 'present', so the report goes silent. An operator following the PRIMARY remedy on a widened posture therefore got no login and turned this report off: the loud-dead-end-to-quiet-dead-end transition remedy (a) warns about, delivered by the remedy the card commissioned. The rider is now scoped to the default posture and carries the ordering the reviewer measured to work — close a widened posture back to `invite_only` BEFORE the invited person registers. All three copies (the string literal, the block comment, the changeset) are aligned again. Also folded in from the same review: - N2: the invitation `email` must be stored LOWERCASE. `hasPendingInvitationFor` searches `where: { email: target.toLowerCase() }`, so a mixed-case row is never found and the sign-up is refused SELF_REGISTRATION_CLOSED — a silent dead end the line was sending operators into. - N1: `(INVALID_EMAIL_OR_PASSWORD)` is row-shape-dependent — an issuer-less hand-written row answers 401, a fully-shaped one with a plaintext password answers 500. Widened to "a 401 or a 500 depending on the row shape, never a session", which holds for both. - N3: dropped the "next boot" timing claim; under `single` the holder is usually promoted at the sign-up itself. The outcome is unchanged, so the line now states the outcome and not the timing. Five new pins, because the clause that was wrong is the one nothing guarded: the scoped phrasing itself; a widened posture admitting the invited creation AND still forcing verification on it (the two mechanisms driven together, since the false claim was that the first cancels the second); only the default posture being mail-transport-free; the structural fact that an admission verdict carries no field that could exempt verification; and N5 — every widening posture in `AUDIENCE_POSTURES` is named in the message, read off the vocabulary so a fourth one cannot leave the line silently stale. ⛔ Still message-text only: no admission semantics, no probe change, sign-up path untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * fix(plugin-auth): say remedy (b) cannot recover an EXISTING person at all Input from #15587's dev, who searched before concluding it: that fix does not make this remedy true, it makes it fail HONESTLY — the person still cannot register a login, and for an EXISTING person that is by design, not by defect (#14349 is closed on option A, the door stays shut, which the line above already cites). The previous wording rested entirely on forced email verification. That is the story for a NEW address. The operator reading this line is usually trying to recover somebody the directory already holds, and for them the posture is not the lever at all: self-registration is a user-CREATION path, so it cannot hand a login to an address that already carries a `sys_user` row, whatever the posture. Both failures are real; they are different failures, and the more likely one was missing. ⛔ Stated in MECHANISM terms, deliberately. #15587 is a draft under review, so this line asserts no wire status for that refusal — the M2 pin that forbids USER_ALREADY_EXISTS / "answers 200" / "persists nothing" / "no new row" in this message stays green, and the new clause is written to keep it that way. Two more pins: the gate ADMITS this address under `open` with no invitation at all — which is what shows the posture is not what stops an existing person, since whatever refuses them sits downstream of it — and the message states the mechanism rather than a status code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent d21ddbb commit 8e500f2

3 files changed

Lines changed: 378 additions & 10 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
The `no_sign_in_account_at_boot` report now names a remedy that works — and warns off the one that silences the report itself.
6+
7+
That boot line fires on the deployment nobody can sign in to: human `sys_user` rows, zero `sys_account` rows. It ended with two remedies, and measured on the exact population it fires on, neither did what its sentence said:
8+
9+
- **"Open the audience posture so an existing person can register their own login"** produced no login, and for an existing person it never can: self-registration is a user-creation path, so it cannot attach a login to an address that already carries a `sys_user` row, whatever the posture. Widening only ever admits a *new* address — and then every posture other than `invite_only` forces `requireEmailVerification` on, so that login is refused `EMAIL_NOT_VERIFIED` at its first sign-in, and a locked-out self-hosted install is usually the shape with no mail transport wired.
10+
- **"Write a `sys_account` credential row directly against the store"** was worse than useless. The `password` column carries a secret in the platform's own hash format, so a plaintext one authenticates nothing — and the probe behind this report asks only whether *any* `sys_account` row exists, so writing one turns the report off. The operator's first attempt at the named remedy turned the loud dead end back into the silent one the report was written to end.
11+
12+
The line now names the path that was measured to work: write one pending `sys_invitation` row directly against the store — a lowercase address the directory does not already hold, `status` `pending`, a future `expires_at`, `inviter_id` of any existing `sys_user` — then register through the ordinary sign-up endpoint. The invitation carve-out admits that one creation under every posture, so no door needs widening. It is an admission verdict and not a verification bypass, though, so the line scopes what follows from that: only under the default `invite_only` posture is the recovery mail-transport-free, and it tells the operator to close a widened posture back to `invite_only` before the invited person registers — otherwise the invited login is created, refused `EMAIL_NOT_VERIFIED` at first sign-in, and has silenced this report on the way past. On the `single` tenancy posture that account holder is then promoted to platform admin. The other two are still named, as the two things that look like remedies and are not, because an operator who is going to hand-write a credential row anyway needs to know it blinds the probe.
13+
14+
**Message text only — no admission semantics move.** Nothing widens, nothing narrows, no accept set changes, and the probe is untouched: this changes what an operator *reads*, not what the platform *admits*. The long form of the same three facts is on the self-hosting deployment page.

‎packages/plugins/plugin-auth/src/boot-sign-in-reachability.test.ts‎

Lines changed: 281 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,19 @@
2626
*/
2727

2828
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
29+
import {
30+
AUDIENCE_POSTURES,
31+
SystemObjectName,
32+
audiencePermitsSelfRegistration,
33+
type AudiencePosture,
34+
} from '@objectstack/spec/system';
2935
import { AuthPlugin } from './auth-plugin';
36+
import { AuthManager } from './auth-manager';
37+
import {
38+
decideAudienceAdmission,
39+
EMAIL_DOMAIN_NOT_ALLOWED,
40+
SELF_REGISTRATION_CLOSED,
41+
} from './audience-posture';
3042
import {
3143
NO_SIGN_IN_ACCOUNT_AT_BOOT,
3244
HUMAN_POPULATION_PROBE_LIMIT,
@@ -117,15 +129,57 @@ describe('#14353 — the dead-end shape reports, by name, with consequence AND r
117129
expect(msg).toContain('401');
118130
});
119131

120-
it('the report NAMES BOTH REMEDIES — the card is only closed if it is actionable', () => {
132+
it('[#15588] NAMES THE REMEDY THAT WORKS, and warns off the two that do not', () => {
121133
const msg = resolveNoSignInAccountReport(DEAD_END)!;
122-
expect(msg).toContain('PROVISION AN ACCOUNT OUT OF BAND');
123-
expect(msg).toContain('OPEN THE AUDIENCE POSTURE');
134+
// The primary way out, spelled as the one row an operator has to write.
135+
expect(msg).toContain('INVITE ONE ADDRESS');
136+
expect(msg).toContain(SystemObjectName.INVITATION);
137+
expect(msg).toContain("'pending'");
138+
expect(msg).toContain('expires_at');
139+
expect(msg).toContain('inviter_id');
140+
// The two that LOOK like remedies. The self-silencing clause is the one
141+
// that must never be dropped for brevity: an operator who is going to
142+
// hand-write a row anyway needs to know it blinds the probe.
143+
expect(msg).toContain('SILENCES THIS REPORT');
144+
expect(msg).toContain('EMAIL_NOT_VERIFIED');
124145
// Real posture spellings from the spec vocabulary, not invented ones.
125146
expect(msg).toContain("'open'");
126147
expect(msg).toContain("'email_domain'");
127148
});
128149

150+
it('[#15588/F1] the no-mail-transport rider is SCOPED to the default posture', () => {
151+
// ⛔ This rider was WRONG in the first version of this line, stated for
152+
// EVERY posture. The carve-out is an ADMISSION verdict, not a verification
153+
// bypass, so on `open`/`email_domain` the INVITED login is created and
154+
// then refused EMAIL_NOT_VERIFIED — and creating it SILENCES this report
155+
// on the way past. That is the loud-dead-end-to-quiet-dead-end transition
156+
// remedy (b) warns about, delivered by the primary remedy. The exact
157+
// scoped phrasing is asserted so no future edit can re-generalise it.
158+
const msg = resolveNoSignInAccountReport(DEAD_END)!;
159+
expect(msg).toContain('ADMISSION verdict, not a verification bypass');
160+
expect(msg).toContain(
161+
"under the default 'invite_only' posture no mail transport is needed either",
162+
);
163+
// the rider must name the invited login as the thing verification hits …
164+
expect(msg).toContain('forces email verification on the INVITED login too');
165+
// … and give the operator the ORDER, which is the actionable half
166+
expect(msg).toContain("close the posture back to 'invite_only' BEFORE that person registers");
167+
// [#15588/N2] the lookup lowercases, so a mixed-case row is never found
168+
expect(msg).toContain('LOWERCASE address');
169+
});
170+
171+
it('[#15588] says NOTHING about what a SEEDED person\u2019s own re-registration answers', () => {
172+
// That response is #15587's surface and is being changed (a sign-up for an
173+
// address that already carries a `sys_user` row currently answers 200 and
174+
// persists nothing; it is becoming an explicit refusal). A boot line that
175+
// asserted either behaviour would be stale the day that lands, so this
176+
// message is written to be true on BOTH sides of it and this pin holds it
177+
// there.
178+
const msg = resolveNoSignInAccountReport(DEAD_END)!;
179+
expect(msg).not.toContain('USER_ALREADY_EXISTS');
180+
expect(msg).not.toMatch(/persists nothing|answers 200|silently|no new row/i);
181+
});
182+
129183
it('names WHY the bootstrap carve-out does not rescue this deployment', () => {
130184
// The #14349 ruling (option A, 2026-09-02) licensed saying plainly that
131185
// the door stays shut; a remedy clause that implied the next visitor
@@ -163,6 +217,230 @@ describe('#14353 — the controls: every other shape is SILENT', () => {
163217
});
164218
});
165219

220+
// ---------------------------------------------------------------------------
221+
// [#15588] The remedy clauses, pinned against the BEHAVIOUR each one describes
222+
//
223+
// A `toContain` on this message proves only that somebody typed the words —
224+
// and the defect this card closes was a message whose sentences were FALSE
225+
// while every string assertion around them stayed green. So each pin below
226+
// drives the mechanism its sentence rests on. Each one fails the day that
227+
// remedy stops working, which is precisely the day the sentence has to be
228+
// rewritten.
229+
//
230+
// ⛔ Nothing here changes an admission decision: these call the existing
231+
// pure decision function and the existing probe, and assert.
232+
// ---------------------------------------------------------------------------
233+
234+
/**
235+
* A self-serve sign-up by the invited address, judged by the real gate.
236+
* The `email_domain` allowlist deliberately holds a DIFFERENT domain, so an
237+
* admission under that posture can only have come from the invitation
238+
* carve-out and never from the domain list.
239+
*/
240+
const invitedSignUp = (posture: AudiencePosture, hasPendingInvitation: boolean) =>
241+
decideAudienceAdmission({
242+
audience: { posture, allowedEmailDomains: ['not-the-invitee.example'] },
243+
creationClass: 'self-serve',
244+
email: 'recovery@example.com',
245+
hasPendingInvitation,
246+
isBootstrap: false,
247+
});
248+
249+
/** A manager carrying one audience declaration, for the wiring mirror. */
250+
const AUDIENCE_TEST_SECRET = 'test-secret-at-least-32-chars-long!!';
251+
const managerWith = (audience?: Record<string, unknown>) =>
252+
new AuthManager({
253+
secret: AUDIENCE_TEST_SECRET,
254+
baseUrl: 'http://localhost:3000',
255+
dataEngine: engineOver({}).engine,
256+
...(audience ? { audience } : {}),
257+
} as never);
258+
259+
/** The declaration a widening posture needs to pass entry validation. */
260+
const wideningAudience = (posture: AudiencePosture) => ({
261+
posture,
262+
...(posture === 'email_domain' ? { allowedEmailDomains: ['acme.example'] } : {}),
263+
selfRegistrationPermissionSet: 'member_default',
264+
});
265+
266+
describe('#15588 — the named remedy WORKS: a pending invitation is admitted under EVERY posture', () => {
267+
it('every posture in the vocabulary admits it — the message says "under EVERY audience posture"', () => {
268+
// Reading the vocabulary rather than listing it: a posture added later is
269+
// covered on the day it is added, and the message's "EVERY" stays honest.
270+
expect(AUDIENCE_POSTURES.length).toBeGreaterThan(1);
271+
for (const posture of AUDIENCE_POSTURES) {
272+
expect(invitedSignUp(posture, true), `posture ${posture}`).toMatchObject({ admit: true });
273+
}
274+
});
275+
276+
it('CONTROL — the same sign-up WITHOUT the invitation row is refused', () => {
277+
// Without this, the pin above would read green on a gate that admitted
278+
// everybody, and the message would be recommending a row nobody needs.
279+
expect(invitedSignUp('invite_only', false)).toMatchObject({
280+
admit: false,
281+
code: SELF_REGISTRATION_CLOSED,
282+
});
283+
expect(invitedSignUp('email_domain', false)).toMatchObject({
284+
admit: false,
285+
code: EMAIL_DOMAIN_NOT_ALLOWED,
286+
});
287+
});
288+
289+
it('CONTROL — the carve-out is what admits it, not the creation class', () => {
290+
// `self-serve` is the posture-gated class; an operator/provider creation
291+
// is exempt for its own reason and would mask a dead carve-out.
292+
expect(invitedSignUp('invite_only', true)).toMatchObject({ admit: true });
293+
expect(
294+
decideAudienceAdmission({
295+
audience: { posture: 'invite_only', allowedEmailDomains: [] },
296+
creationClass: 'self-serve',
297+
email: 'recovery@example.com',
298+
hasPendingInvitation: false,
299+
isBootstrap: false,
300+
}),
301+
).toMatchObject({ admit: false });
302+
});
303+
});
304+
305+
describe('#15588 — the WARNING is true: ANY hand-written `sys_account` row silences this report', () => {
306+
it('one credential row — plaintext password and all — turns the report OFF', async () => {
307+
// This is the sentence "writing ANY sys_account row SILENCES THIS REPORT,
308+
// which asks only whether such a row EXISTS". If the probe is ever
309+
// tightened (a separate card — it would move #14353's diagnostic
310+
// semantics), this pin fails and the message clause must be rewritten.
311+
const handWritten = {
312+
id: 'acc_handwritten',
313+
user_id: 'usr_1',
314+
provider_id: 'credential',
315+
password: 'a-plaintext-password-that-authenticates-nothing',
316+
};
317+
const { engine } = engineOver({ users: HUMANS, accounts: [handWritten] });
318+
const facts = await probeSignInReachability(engine);
319+
expect(facts).toEqual({ humanUsers: 'present', signInAccounts: 'present' });
320+
expect(resolveNoSignInAccountReport(facts)).toBeNull();
321+
});
322+
323+
it('CONTROL — the identical store WITHOUT that row still reports', async () => {
324+
const { engine } = engineOver({ users: HUMANS, accounts: [] });
325+
const facts = await probeSignInReachability(engine);
326+
expect(facts).toEqual({ humanUsers: 'present', signInAccounts: 'absent' });
327+
expect(resolveNoSignInAccountReport(facts)).toContain(NO_SIGN_IN_ACCOUNT_AT_BOOT);
328+
});
329+
});
330+
331+
describe('#15588 — the WARNING is true: widening the posture FORCES email verification on', () => {
332+
it('`email_domain` and `open` both wire requireEmailVerification ON', () => {
333+
// The manager forces it from `audiencePermitsSelfRegistration(posture)` and
334+
// `getPublicConfig()` mirrors the wired flag, so this is the same fact the
335+
// message reports: a login registered under a widened posture cannot sign
336+
// in until a mail transport delivers the link.
337+
for (const posture of ['email_domain', 'open'] as const) {
338+
expect(
339+
managerWith(wideningAudience(posture)).getPublicConfig().emailPassword
340+
.requireEmailVerification,
341+
`posture ${posture}`,
342+
).toBe(true);
343+
}
344+
});
345+
346+
it('CONTROL — the default `invite_only` posture does NOT force it', () => {
347+
// The forcing is a property of WIDENING, not a constant `true` the pin
348+
// above would be satisfied by either way.
349+
expect(managerWith().getPublicConfig().emailPassword.requireEmailVerification).toBe(false);
350+
});
351+
352+
it("every posture other than 'invite_only' is one that widens — the message's exact claim", () => {
353+
// The message says "every posture other than 'invite_only'". A fourth
354+
// posture that did NOT permit self-registration would make that sentence
355+
// wrong, and this is where that is caught.
356+
for (const posture of AUDIENCE_POSTURES) {
357+
expect(
358+
managerWith(
359+
posture === 'invite_only' ? undefined : wideningAudience(posture),
360+
).getPublicConfig().emailPassword.requireEmailVerification,
361+
`posture ${posture}`,
362+
).toBe(posture !== 'invite_only');
363+
}
364+
});
365+
});
366+
367+
describe('#15588/F1 — the carve-out ADMITS, it does not EXEMPT: the rider\u2019s scope, pinned', () => {
368+
// The clause that was wrong is the one nothing guarded. These drive the two
369+
// mechanisms together — the carve-out and the verification wiring — because
370+
// the false claim was precisely that the first cancels the second.
371+
372+
it('a widened posture admits the INVITED creation AND still forces verification on it', () => {
373+
for (const posture of AUDIENCE_POSTURES.filter(audiencePermitsSelfRegistration)) {
374+
// admitted by the carve-out …
375+
expect(invitedSignUp(posture, true), `posture ${posture}`).toMatchObject({ admit: true });
376+
// … into a runtime that will refuse its first sign-in EMAIL_NOT_VERIFIED.
377+
expect(
378+
managerWith(wideningAudience(posture)).getPublicConfig().emailPassword
379+
.requireEmailVerification,
380+
`posture ${posture}`,
381+
).toBe(true);
382+
}
383+
});
384+
385+
it('ONLY the default posture is mail-transport-free — the half the message still promises', () => {
386+
expect(invitedSignUp('invite_only', true)).toMatchObject({ admit: true });
387+
expect(managerWith().getPublicConfig().emailPassword.requireEmailVerification).toBe(false);
388+
});
389+
390+
it('an admission verdict CANNOT express a verification exemption', () => {
391+
// Structural, and the reason the two mechanisms can never cancel: the
392+
// carve-out's verdict carries no field that could turn verification off
393+
// for the invited login. If one is ever added, the rider's scope changes
394+
// and this is where that gets noticed.
395+
expect(Object.keys(invitedSignUp('open', true)).sort()).toEqual([
396+
'admit',
397+
'grantPermissionSet',
398+
]);
399+
});
400+
});
401+
402+
describe('#15588 — remedy (b) cannot recover an EXISTING person, and the posture is not the lever', () => {
403+
it('under `open` the gate ADMITS the address outright — so the posture is not what stops them', () => {
404+
// The message says widening "cannot recover an EXISTING person at all …
405+
// and NO posture changes that". This is the half that shows WHY the
406+
// posture is not the lever: the audience gate is a CREATION gate and it
407+
// already says yes for this address with no invitation at all. Whatever
408+
// refuses an existing person therefore sits DOWNSTREAM of the posture, so
409+
// moving the posture cannot reach it — which is exactly what the message
410+
// now tells the operator, instead of sending them to widen it.
411+
expect(invitedSignUp('open', false)).toMatchObject({ admit: true });
412+
});
413+
414+
it('the message states it in MECHANISM terms — the operator is told WHY, not a status code', () => {
415+
const msg = resolveNoSignInAccountReport(DEAD_END)!;
416+
expect(msg).toContain('cannot recover an EXISTING person at all');
417+
expect(msg).toContain('user-CREATION path');
418+
expect(msg).toContain('NO posture changes that');
419+
// and the half that IS about a new address survives beside it
420+
expect(msg).toContain('Widening only ever admits a NEW address');
421+
});
422+
});
423+
424+
describe('#15588/N5 — the message NAMES every posture that widens, read off the vocabulary', () => {
425+
it('every self-registration-permitting posture appears in the message by name', () => {
426+
// The message's quantifier ("every posture other than 'invite_only'") is
427+
// pinned elsewhere against the WIRING. This pins the spellings the message
428+
// hands the operator, read off the vocabulary rather than hardcoded, so a
429+
// posture added to `AUDIENCE_POSTURES` cannot leave this line silently
430+
// stale — it is named nowhere in the message and this goes red.
431+
const msg = resolveNoSignInAccountReport(DEAD_END)!;
432+
const widening = AUDIENCE_POSTURES.filter(audiencePermitsSelfRegistration);
433+
expect(widening.length).toBeGreaterThan(0);
434+
for (const posture of widening) {
435+
expect(msg, `posture ${posture} must be named in the message`).toContain(`'${posture}'`);
436+
}
437+
// and the one that does NOT widen is named as the exception it is
438+
expect(AUDIENCE_POSTURES.filter((p) => !audiencePermitsSelfRegistration(p))).toEqual([
439+
'invite_only',
440+
]);
441+
});
442+
});
443+
166444
// ---------------------------------------------------------------------------
167445
// The probes
168446
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)