diff --git a/.changeset/boot-sign-in-report-remedy-text.md b/.changeset/boot-sign-in-report-remedy-text.md new file mode 100644 index 0000000000..2f330fb333 --- /dev/null +++ b/.changeset/boot-sign-in-report-remedy-text.md @@ -0,0 +1,14 @@ +--- +"@objectstack/plugin-auth": patch +--- + +The `no_sign_in_account_at_boot` report now names a remedy that works — and warns off the one that silences the report itself. + +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: + +- **"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. +- **"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. + +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. + +**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. diff --git a/packages/plugins/plugin-auth/src/boot-sign-in-reachability.test.ts b/packages/plugins/plugin-auth/src/boot-sign-in-reachability.test.ts index e0eaf4d9a5..1cd2bc3f44 100644 --- a/packages/plugins/plugin-auth/src/boot-sign-in-reachability.test.ts +++ b/packages/plugins/plugin-auth/src/boot-sign-in-reachability.test.ts @@ -26,7 +26,19 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + AUDIENCE_POSTURES, + SystemObjectName, + audiencePermitsSelfRegistration, + type AudiencePosture, +} from '@objectstack/spec/system'; import { AuthPlugin } from './auth-plugin'; +import { AuthManager } from './auth-manager'; +import { + decideAudienceAdmission, + EMAIL_DOMAIN_NOT_ALLOWED, + SELF_REGISTRATION_CLOSED, +} from './audience-posture'; import { NO_SIGN_IN_ACCOUNT_AT_BOOT, HUMAN_POPULATION_PROBE_LIMIT, @@ -117,15 +129,57 @@ describe('#14353 — the dead-end shape reports, by name, with consequence AND r expect(msg).toContain('401'); }); - it('the report NAMES BOTH REMEDIES — the card is only closed if it is actionable', () => { + it('[#15588] NAMES THE REMEDY THAT WORKS, and warns off the two that do not', () => { const msg = resolveNoSignInAccountReport(DEAD_END)!; - expect(msg).toContain('PROVISION AN ACCOUNT OUT OF BAND'); - expect(msg).toContain('OPEN THE AUDIENCE POSTURE'); + // The primary way out, spelled as the one row an operator has to write. + expect(msg).toContain('INVITE ONE ADDRESS'); + expect(msg).toContain(SystemObjectName.INVITATION); + expect(msg).toContain("'pending'"); + expect(msg).toContain('expires_at'); + expect(msg).toContain('inviter_id'); + // The two that LOOK like remedies. The self-silencing clause is the one + // that must never be dropped for brevity: an operator who is going to + // hand-write a row anyway needs to know it blinds the probe. + expect(msg).toContain('SILENCES THIS REPORT'); + expect(msg).toContain('EMAIL_NOT_VERIFIED'); // Real posture spellings from the spec vocabulary, not invented ones. expect(msg).toContain("'open'"); expect(msg).toContain("'email_domain'"); }); + it('[#15588/F1] the no-mail-transport rider is SCOPED to the default posture', () => { + // ⛔ This rider was WRONG in the first version of this line, stated for + // EVERY posture. The carve-out is an ADMISSION verdict, not a verification + // bypass, so on `open`/`email_domain` the INVITED login is created and + // then refused EMAIL_NOT_VERIFIED — and creating it SILENCES this report + // on the way past. That is the loud-dead-end-to-quiet-dead-end transition + // remedy (b) warns about, delivered by the primary remedy. The exact + // scoped phrasing is asserted so no future edit can re-generalise it. + const msg = resolveNoSignInAccountReport(DEAD_END)!; + expect(msg).toContain('ADMISSION verdict, not a verification bypass'); + expect(msg).toContain( + "under the default 'invite_only' posture no mail transport is needed either", + ); + // the rider must name the invited login as the thing verification hits … + expect(msg).toContain('forces email verification on the INVITED login too'); + // … and give the operator the ORDER, which is the actionable half + expect(msg).toContain("close the posture back to 'invite_only' BEFORE that person registers"); + // [#15588/N2] the lookup lowercases, so a mixed-case row is never found + expect(msg).toContain('LOWERCASE address'); + }); + + it('[#15588] says NOTHING about what a SEEDED person\u2019s own re-registration answers', () => { + // That response is #15587's surface and is being changed (a sign-up for an + // address that already carries a `sys_user` row currently answers 200 and + // persists nothing; it is becoming an explicit refusal). A boot line that + // asserted either behaviour would be stale the day that lands, so this + // message is written to be true on BOTH sides of it and this pin holds it + // there. + const msg = resolveNoSignInAccountReport(DEAD_END)!; + expect(msg).not.toContain('USER_ALREADY_EXISTS'); + expect(msg).not.toMatch(/persists nothing|answers 200|silently|no new row/i); + }); + it('names WHY the bootstrap carve-out does not rescue this deployment', () => { // The #14349 ruling (option A, 2026-09-02) licensed saying plainly that // 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', () => { }); }); +// --------------------------------------------------------------------------- +// [#15588] The remedy clauses, pinned against the BEHAVIOUR each one describes +// +// A `toContain` on this message proves only that somebody typed the words — +// and the defect this card closes was a message whose sentences were FALSE +// while every string assertion around them stayed green. So each pin below +// drives the mechanism its sentence rests on. Each one fails the day that +// remedy stops working, which is precisely the day the sentence has to be +// rewritten. +// +// ⛔ Nothing here changes an admission decision: these call the existing +// pure decision function and the existing probe, and assert. +// --------------------------------------------------------------------------- + +/** + * A self-serve sign-up by the invited address, judged by the real gate. + * The `email_domain` allowlist deliberately holds a DIFFERENT domain, so an + * admission under that posture can only have come from the invitation + * carve-out and never from the domain list. + */ +const invitedSignUp = (posture: AudiencePosture, hasPendingInvitation: boolean) => + decideAudienceAdmission({ + audience: { posture, allowedEmailDomains: ['not-the-invitee.example'] }, + creationClass: 'self-serve', + email: 'recovery@example.com', + hasPendingInvitation, + isBootstrap: false, + }); + +/** A manager carrying one audience declaration, for the wiring mirror. */ +const AUDIENCE_TEST_SECRET = 'test-secret-at-least-32-chars-long!!'; +const managerWith = (audience?: Record) => + new AuthManager({ + secret: AUDIENCE_TEST_SECRET, + baseUrl: 'http://localhost:3000', + dataEngine: engineOver({}).engine, + ...(audience ? { audience } : {}), + } as never); + +/** The declaration a widening posture needs to pass entry validation. */ +const wideningAudience = (posture: AudiencePosture) => ({ + posture, + ...(posture === 'email_domain' ? { allowedEmailDomains: ['acme.example'] } : {}), + selfRegistrationPermissionSet: 'member_default', +}); + +describe('#15588 — the named remedy WORKS: a pending invitation is admitted under EVERY posture', () => { + it('every posture in the vocabulary admits it — the message says "under EVERY audience posture"', () => { + // Reading the vocabulary rather than listing it: a posture added later is + // covered on the day it is added, and the message's "EVERY" stays honest. + expect(AUDIENCE_POSTURES.length).toBeGreaterThan(1); + for (const posture of AUDIENCE_POSTURES) { + expect(invitedSignUp(posture, true), `posture ${posture}`).toMatchObject({ admit: true }); + } + }); + + it('CONTROL — the same sign-up WITHOUT the invitation row is refused', () => { + // Without this, the pin above would read green on a gate that admitted + // everybody, and the message would be recommending a row nobody needs. + expect(invitedSignUp('invite_only', false)).toMatchObject({ + admit: false, + code: SELF_REGISTRATION_CLOSED, + }); + expect(invitedSignUp('email_domain', false)).toMatchObject({ + admit: false, + code: EMAIL_DOMAIN_NOT_ALLOWED, + }); + }); + + it('CONTROL — the carve-out is what admits it, not the creation class', () => { + // `self-serve` is the posture-gated class; an operator/provider creation + // is exempt for its own reason and would mask a dead carve-out. + expect(invitedSignUp('invite_only', true)).toMatchObject({ admit: true }); + expect( + decideAudienceAdmission({ + audience: { posture: 'invite_only', allowedEmailDomains: [] }, + creationClass: 'self-serve', + email: 'recovery@example.com', + hasPendingInvitation: false, + isBootstrap: false, + }), + ).toMatchObject({ admit: false }); + }); +}); + +describe('#15588 — the WARNING is true: ANY hand-written `sys_account` row silences this report', () => { + it('one credential row — plaintext password and all — turns the report OFF', async () => { + // This is the sentence "writing ANY sys_account row SILENCES THIS REPORT, + // which asks only whether such a row EXISTS". If the probe is ever + // tightened (a separate card — it would move #14353's diagnostic + // semantics), this pin fails and the message clause must be rewritten. + const handWritten = { + id: 'acc_handwritten', + user_id: 'usr_1', + provider_id: 'credential', + password: 'a-plaintext-password-that-authenticates-nothing', + }; + const { engine } = engineOver({ users: HUMANS, accounts: [handWritten] }); + const facts = await probeSignInReachability(engine); + expect(facts).toEqual({ humanUsers: 'present', signInAccounts: 'present' }); + expect(resolveNoSignInAccountReport(facts)).toBeNull(); + }); + + it('CONTROL — the identical store WITHOUT that row still reports', async () => { + const { engine } = engineOver({ users: HUMANS, accounts: [] }); + const facts = await probeSignInReachability(engine); + expect(facts).toEqual({ humanUsers: 'present', signInAccounts: 'absent' }); + expect(resolveNoSignInAccountReport(facts)).toContain(NO_SIGN_IN_ACCOUNT_AT_BOOT); + }); +}); + +describe('#15588 — the WARNING is true: widening the posture FORCES email verification on', () => { + it('`email_domain` and `open` both wire requireEmailVerification ON', () => { + // The manager forces it from `audiencePermitsSelfRegistration(posture)` and + // `getPublicConfig()` mirrors the wired flag, so this is the same fact the + // message reports: a login registered under a widened posture cannot sign + // in until a mail transport delivers the link. + for (const posture of ['email_domain', 'open'] as const) { + expect( + managerWith(wideningAudience(posture)).getPublicConfig().emailPassword + .requireEmailVerification, + `posture ${posture}`, + ).toBe(true); + } + }); + + it('CONTROL — the default `invite_only` posture does NOT force it', () => { + // The forcing is a property of WIDENING, not a constant `true` the pin + // above would be satisfied by either way. + expect(managerWith().getPublicConfig().emailPassword.requireEmailVerification).toBe(false); + }); + + it("every posture other than 'invite_only' is one that widens — the message's exact claim", () => { + // The message says "every posture other than 'invite_only'". A fourth + // posture that did NOT permit self-registration would make that sentence + // wrong, and this is where that is caught. + for (const posture of AUDIENCE_POSTURES) { + expect( + managerWith( + posture === 'invite_only' ? undefined : wideningAudience(posture), + ).getPublicConfig().emailPassword.requireEmailVerification, + `posture ${posture}`, + ).toBe(posture !== 'invite_only'); + } + }); +}); + +describe('#15588/F1 — the carve-out ADMITS, it does not EXEMPT: the rider\u2019s scope, pinned', () => { + // The clause that was wrong is the one nothing guarded. These drive the two + // mechanisms together — the carve-out and the verification wiring — because + // the false claim was precisely that the first cancels the second. + + it('a widened posture admits the INVITED creation AND still forces verification on it', () => { + for (const posture of AUDIENCE_POSTURES.filter(audiencePermitsSelfRegistration)) { + // admitted by the carve-out … + expect(invitedSignUp(posture, true), `posture ${posture}`).toMatchObject({ admit: true }); + // … into a runtime that will refuse its first sign-in EMAIL_NOT_VERIFIED. + expect( + managerWith(wideningAudience(posture)).getPublicConfig().emailPassword + .requireEmailVerification, + `posture ${posture}`, + ).toBe(true); + } + }); + + it('ONLY the default posture is mail-transport-free — the half the message still promises', () => { + expect(invitedSignUp('invite_only', true)).toMatchObject({ admit: true }); + expect(managerWith().getPublicConfig().emailPassword.requireEmailVerification).toBe(false); + }); + + it('an admission verdict CANNOT express a verification exemption', () => { + // Structural, and the reason the two mechanisms can never cancel: the + // carve-out's verdict carries no field that could turn verification off + // for the invited login. If one is ever added, the rider's scope changes + // and this is where that gets noticed. + expect(Object.keys(invitedSignUp('open', true)).sort()).toEqual([ + 'admit', + 'grantPermissionSet', + ]); + }); +}); + +describe('#15588 — remedy (b) cannot recover an EXISTING person, and the posture is not the lever', () => { + it('under `open` the gate ADMITS the address outright — so the posture is not what stops them', () => { + // The message says widening "cannot recover an EXISTING person at all … + // and NO posture changes that". This is the half that shows WHY the + // posture is not the lever: the audience gate is a CREATION gate and it + // already says yes for this address with no invitation at all. Whatever + // refuses an existing person therefore sits DOWNSTREAM of the posture, so + // moving the posture cannot reach it — which is exactly what the message + // now tells the operator, instead of sending them to widen it. + expect(invitedSignUp('open', false)).toMatchObject({ admit: true }); + }); + + it('the message states it in MECHANISM terms — the operator is told WHY, not a status code', () => { + const msg = resolveNoSignInAccountReport(DEAD_END)!; + expect(msg).toContain('cannot recover an EXISTING person at all'); + expect(msg).toContain('user-CREATION path'); + expect(msg).toContain('NO posture changes that'); + // and the half that IS about a new address survives beside it + expect(msg).toContain('Widening only ever admits a NEW address'); + }); +}); + +describe('#15588/N5 — the message NAMES every posture that widens, read off the vocabulary', () => { + it('every self-registration-permitting posture appears in the message by name', () => { + // The message's quantifier ("every posture other than 'invite_only'") is + // pinned elsewhere against the WIRING. This pins the spellings the message + // hands the operator, read off the vocabulary rather than hardcoded, so a + // posture added to `AUDIENCE_POSTURES` cannot leave this line silently + // stale — it is named nowhere in the message and this goes red. + const msg = resolveNoSignInAccountReport(DEAD_END)!; + const widening = AUDIENCE_POSTURES.filter(audiencePermitsSelfRegistration); + expect(widening.length).toBeGreaterThan(0); + for (const posture of widening) { + expect(msg, `posture ${posture} must be named in the message`).toContain(`'${posture}'`); + } + // and the one that does NOT widen is named as the exception it is + expect(AUDIENCE_POSTURES.filter((p) => !audiencePermitsSelfRegistration(p))).toEqual([ + 'invite_only', + ]); + }); +}); + // --------------------------------------------------------------------------- // The probes // --------------------------------------------------------------------------- diff --git a/packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts b/packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts index 7894ce31f5..cd094e5a30 100644 --- a/packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts +++ b/packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts @@ -29,6 +29,63 @@ * stays shut and the remedy is out-of-band provisioning". The message below * says exactly that. * + * ## [#15588] Why the remedy names an INVITATION, and warns off the other two + * + * The first version of this line ended with two remedies, and measured on the + * exact population this report fires on, NEITHER did what its sentence said. + * The message is the only thing that changed for #15588 — no admission + * semantics move, exactly as #14353 scoped itself — but each clause it now + * carries is a claim about behaviour, and each is pinned in the sibling suite + * rather than merely string-matched: + * + * - **The invitation row is the way out.** `decideAudienceAdmission` short- + * circuits on `hasPendingInvitation` BEFORE the posture switch, so a + * self-serve creation holding a pending, unexpired `sys_invitation` + * (`audience-posture.ts`, "The invitation carve-out") is admitted under + * every posture in `AUDIENCE_POSTURES`. No door needs widening. ⚠️ But the + * carve-out is an ADMISSION verdict and NOT a verification bypass, so the + * third bullet below applies to the INVITED login too: only under the + * default `invite_only` posture is the recovery mail-transport-free, and on + * `open`/`email_domain` the invited login is created and then refused + * `EMAIL_NOT_VERIFIED` — which also SILENCES this report, the very + * transition the second bullet warns about, delivered by the primary + * remedy. The message therefore scopes the no-mail-transport rider and + * tells the operator to close the posture first; ⛔ never re-state that + * rider for EVERY posture. Pinned across the whole posture vocabulary, with + * the un-invited `invite_only` refusal as its control, and the scope itself + * pinned beside it. + * - **A hand-written credential row blinds this very check.** + * {@link probeSignInAccountsPresence} asks only whether ANY + * `sys_account` row exists, so the operator's first attempt at "provision + * an account out of band" turns the loud dead end back into the silent one + * this report was written to end — while a plaintext `password` column + * still authenticates nothing. ⛔ TIGHTENING THE PROBE IS NOT THIS CARD: + * that would move #14353's diagnostic semantics. The message tells the + * truth about today's probe instead, and the pin fails the day the probe + * changes — which is the day this sentence must be rewritten. + * - **Widening the posture cannot recover an EXISTING person at all, and + * only half-works for a new one.** The operator reading this line is + * usually trying to recover somebody the directory already holds, and for + * them the posture is not the lever: 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 — the audience gate ADMITS such an + * address under `open` (pinned), which is precisely why moving the posture + * cannot reach what refuses them. ⚠️ Stated in MECHANISM terms on purpose: + * what that refusal answers on the wire is #15587's surface, in flight, and + * this line must stay true on both sides of it — a pin holds it there. + * For a NEW address, widening does create the account, and then + * `audiencePermitsSelfRegistration(posture)` drives `createAuthInstance()` + * to wire `requireEmailVerification: true` (mirrored by + * `getPublicConfig()`), so that login is refused `EMAIL_NOT_VERIFIED` at + * first sign-in — no login at all on a deployment with no mail transport, + * which is the shape a locked-out self-hosted install usually is. + * + * The message deliberately does NOT describe what a SEEDED person's own + * re-registration answers: that response is #15587's surface and is being + * changed. Every clause above holds on both sides of that landing. + * `content/docs/deployment/self-hosting.mdx` (#14495) is the long form of the + * same three facts; this line and that page must keep agreeing. + * * ## Why this is `error` and the walled-owner neighbour is `warn` * * AGENTS.md → "Degradation log levels" decides with one question: after the @@ -218,13 +275,32 @@ export function resolveNoSignInAccountReport(facts: SignInReachabilityFacts): st `(maintainer ruling 2026-09-02, option A — the door stays shut); self-registration is refused with ` + `${SELF_REGISTRATION_CLOSED} under the default 'invite_only' audience posture; and no administrator ` + 'exists who could send an invitation. Boot continues and this deployment will keep LOOKING healthy — ' + - 'its only symptom is a 401 on credentials nobody holds. Fix it from OUTSIDE the running product, ' + - `either: (1) PROVISION AN ACCOUNT OUT OF BAND — write a '${SystemObjectName.ACCOUNT}' credential row ` + - `for one of the existing '${SystemObjectName.USER}' rows directly against the store, or re-run the ` + - 'provisioning job that seeded those people so it seeds their logins too; or (2) OPEN THE AUDIENCE ' + - "POSTURE — set `audience.posture` to 'open', or to 'email_domain' with your directory's domain " + - 'allowlisted, so an existing person can register their own login, then close it again. Neither ' + - 'happens by itself.' + 'its only symptom is a 401 on credentials nobody holds. RECOVER IT FROM OUTSIDE THE RUNNING ' + + 'PRODUCT, and the path that works is ONE ROW: INVITE ONE ADDRESS — write a pending ' + + `'${SystemObjectName.INVITATION}' row directly against the store ('email' a LOWERCASE address ` + + `this directory does NOT already hold, 'status' 'pending', a future 'expires_at', 'inviter_id' ` + + `the id of any existing '${SystemObjectName.USER}' row — the pending-invitation lookup ` + + 'lowercases the address it searches for, so a mixed-case row is never found), then have that ' + + 'person register through the ordinary sign-up endpoint. The invitation carve-out admits that ' + + 'ONE creation under EVERY audience posture, so no door needs widening — but it is an ADMISSION ' + + "verdict, not a verification bypass: under the default 'invite_only' posture no mail transport " + + "is needed either, whereas an 'open' or 'email_domain' posture forces email verification on the " + + "INVITED login too (see (b)), so close the posture back to 'invite_only' BEFORE that person " + + "registers. On the 'single' tenancy posture that account holder is then promoted to platform " + + 'admin. Afterwards, re-run the provisioning job that seeded these people so it seeds their ' + + 'logins too. TWO THINGS THAT LOOK LIKE REMEDIES AND ARE NOT: (a) HAND-WRITING A CREDENTIAL ' + + `ROW — a '${SystemObjectName.ACCOUNT}' row's 'password' column must carry a secret in the ` + + "platform's own hash format, so a plaintext password authenticates nothing — a 401 or a 500 " + + 'depending on the row shape, never a session — and ' + + `writing ANY '${SystemObjectName.ACCOUNT}' row SILENCES THIS REPORT, which asks only whether such ` + + 'a row EXISTS — the deployment stops being loudly broken and becomes quietly broken; and (b) ' + + 'OPENING THE AUDIENCE POSTURE — which cannot recover an EXISTING person at all: ' + + 'self-registration is a user-CREATION path, so it cannot hand a login to somebody whose ' + + `'${SystemObjectName.USER}' row already exists, and NO posture changes that. Widening only ever ` + + "admits a NEW address — and then every posture other than 'invite_only' ('open', " + + "'email_domain') FORCES email verification ON, so that login is refused EMAIL_NOT_VERIFIED at " + + 'its first sign-in until a mail transport delivers the link, and a locked-out self-hosted ' + + 'install usually has none. Nothing here happens by itself.' ); }