Skip to content

Commit 87c37ae

Browse files
fix(plugin-auth): the no-sign-in-account boot report states the email-verification rule as shipped (#20434)
Fixes #20413 Clause-②: no Round 2 of the card, location 4: the plugin-auth boot report. Round 1 (PR #20421, landed as `24b70859`) covered locations 1–3. This round completes the card. It changes text, comments and tests only. No admission decision, verification default or accepted value moves. ## What changed The `no_sign_in_account_at_boot` report tells an operator to recover a locked-out deployment by inviting one address. Its advice said an `'open'` or `'email_domain'` posture "forces email verification on the INVITED login too". Later, in remedy (b), the same message said verification is forced "unless an 'open' deployment has itself declared it off". The first sentence stopped being true when `open` began honouring a deployment's opt-out (`65352b7d`, #20406), so the message contradicted itself. **The rule, re-derived from `packages/plugins/plugin-auth/src` at `24b70859`:** - `resolveEmailVerificationRequirement` (audience-posture.ts), the one resolver the better-auth wiring and `getPublicConfig()` both read: - `email_domain` is always on; - `open` is on unless the declared value is `false`; - `invite_only` uses the declared value, off by default. - `assertAudienceConfig` refuses an explicit `false` under `email_domain` from any source. Under `open` it refuses a `false` whose declarant is `console`. - `applyConfigPatch` (auth-manager.ts) marks a patch as the deployment's unless its caller passes `requireEmailVerificationFrom: 'console'`. - The invitation carve-out in `decideAudienceAdmission` returns only `admit` and `grantPermissionSet`, so it cannot exempt anyone from verification. The only row born `emailVerified: true` is the walled-owner operator stamp, and an invitation-admitted creation never gets it. **The message now:** - states the rule once, in the invitation paragraph: - the INVITED login meets the same email-verification rule as any sign-up; - `email_domain` is always ON; - `open` is ON unless the DEPLOYMENT declared it off (`emailAndPassword.requireEmailVerification: false` or `OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false`), and a `false` stored only through the settings console is refused there; - `invite_only` follows that declaration and is OFF by default. - keeps the scoped no-mail-transport rider and the advice order ("close the posture back to 'invite_only' BEFORE that person registers"). It adds "and leave verification at its default OFF", because under `invite_only` a declared `true` would still apply. - in remedy (b), refers back to that rule ("wherever it is ON") instead of restating it. The advice still matches `content/docs/deployment/self-hosting.mdx` ("Email verification under each posture"), which the module docblock names as this message's long form. That page already stated the rule correctly. **Files:** - `packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts`: the remedy string, plus the file docblock's invitation bullet (the old "third bullet below applies to the INVITED login too" sentence). - `packages/plugins/plugin-auth/src/boot-sign-in-reachability.test.ts`: - the pin at the old line 164 is flipped. It now asserts the new text's substance: the invited-login rule, `email_domain` always ON, the `open` conditional, the console refusal, and the rider's new wording. - two negative pins are added, one per old unconditional spelling. - a new `#20389` describe drives each clause against the manager: `open` + the deployment's `false` wires OFF; the same `false` from the console is refused and stays ON, with the deployment patch as control; `email_domain` + `false` refuses the boot, with a control; `invite_only` is OFF by default and ON when declared. - three titles and comments that repeated the old quantifier now say "with nothing declared". - `managerWith` takes an optional deployment `emailAndPassword` block. - `packages/plugins/plugin-auth/src/auth-manager.ts`: one comment near the old line 2295 (the #15587 uniqueness-refusal block), restated as "on by default". - `packages/plugins/plugin-auth/src/signup-existing-address-refusal.test.ts`: one docblock sentence (old line 30), restated the same way. - `.changeset/20413-boot-report-verification-text.md`: `@objectstack/plugin-auth` `patch`. The boot report prose ships. Stale-phrase counts across `packages/plugins/plugin-auth/src`, BASE `24b70859` → HEAD `7898a47d`: "INVITED login too" 2 → 0; "every posture other than 'invite_only' ('open'," 1 → 0; "self-registration FORCES `requireEmailVerification` on (see" 1 → 0; "self-registration-permitting posture FORCES `requireEmailVerification`" 1 → 0; "third bullet below applies to the INVITED login too" 1 → 0. ## Verification (all at HEAD `7898a47d`) - **Build:** `pnpm --workspace-concurrency=2 --filter '@objectstack/plugin-auth^...' build`, run through the verify lock: `VERDICT command-exit 0`. Then `pnpm --filter @objectstack/plugin-auth build`: check-dts-emitted 2/2. - **Targeted tests:** `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/boot-sign-in-reachability.test.ts src/signup-existing-address-refusal.test.ts` gave `Test Files 2 passed (2)` / `Tests 59 passed (59)`. - **Package tests:** `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2` gave `Test Files 114 passed (114)` / `Tests 2458 passed (2458)`. - **Typecheck:** `pnpm --filter @objectstack/plugin-auth run typecheck` exited 0. It runs `tsc --noEmit`, the examples project, and `check:test-typecheck`, which reported "OK … 10 file(s) / 94 error(s) … held in test-typecheck-debt.json". - **Reverse verification.** The fix was committed first. Each leg ran through `scripts/ablation-replace.mjs` in WRAP mode, with a driver trap that restores from HEAD by absolute path. The test imports the module by relative path, so it reads `src/` and no rebuild was needed between legs. - Leg A put the old sentence back in place of the new one. The anchor went 1 → 0 and the blob went `b0be40fa` → `d1b521c9`. Result: `Tests 1 failed | 50 passed (51)`, failing `[#15588/F1] the no-mail-transport rider is SCOPED to the default posture` with "expected '[auth] no_sign_in_account_at_boot: th…' to contain 'under the default \'invite_only\' pos…'". - Leg B added the old sentence beside the new text. Result: the same single test red, on the negative pin "not to match /an 'open' or 'email_domain' posture …/i". - Leg C added the old remedy-(b) quantifier. Result: the same single test red, on "not to match /every posture other than 'invite_onl…/i". - After all three legs, the blob equals the HEAD blob (`b0be40fa`) and `git diff HEAD` is empty. The direction observed was red, as expected. - **Lint, narrowed to the 4 touched `.ts` files:** `eslint --no-inline-config --format json` linted 4 files with 0 errors and 0 warnings. `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot change a verdict on an untouched file. The repo-wide run belongs to CI. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 64 commands over these 5 paths. `--ran` reconciled "64 derived, 62 run, 2 NOT-MEASURED, 0 UNRUN". All 62 that ran exited 0. - NOT MEASURED: `pnpm check:dual-build-cjs-loads` exited 3 with PREREQUISITE NOT MET, because it needs every package's `dist/` and this tree built only the plugin-auth closure. Declared narrowing, applied to the one touched package: `node --check dist/index.js` exits 0, and `require('./dist/index.js')` loads 143 exports. The new text is present in `dist/index.js` and `dist/index.mjs` (1 each), and the old phrase in neither (0 each). - NOT MEASURED: `pnpm check:type-check-debt` exited 3 with PREREQUISITE NOT MET, because `@objectstack/runtime`, `service-cluster` and `service-job` have no built types here. Declared narrowing: the ledger's DEBT keys are `cloud-connection`, `hono`, `observability` and `spec-monorepo`, none of which depends on plugin-auth, and TEST_DEBT is empty. The only non-comment source change is the content of one string literal, so no type moves. - `check:nul-bytes`: OK. Control-byte self-scan over the 5 files: no hits. ## Acceptance notes Sweep, at `24b70859`: lines naming email verification (`email.?verification|requireEmailVerification|EMAIL_NOT_VERIFIED|REQUIRE_EMAIL_VERIFICATION`) that also carry a forcing or quantifier word (`forc|other than invite_only|every posture|permitting posture|widen`). That gave 51 lines in 19 files, and every hit outside round 1's surface was read in context. In the files this round owns, all hits are fixed. **Stale or incomplete outside this round's surface. All are comments or frozen history; none is edited here.** - `packages/plugins/plugin-auth/src/audience-gate-test-support.ts:17`: "`open` (and `email_domain`) force `requireEmailVerification` on". This is a docblock explaining why fixtures use the invitation lane. It is true for the undeclared fixture config but omits `open`'s opt-out. No carrier. - `packages/plugins/plugin-auth/src/admin-remove-user-gate-ordering.test.ts:135`: "`open` would force email verification on". The same shape, in a test comment. No carrier. - `packages/verify/src/harness.ts:778`: "`open` and `email_domain` force `requireEmailVerification` on". The same shape, in a comment. No carrier. - `packages/spec/src/migrations/entries/semantic/18.audience-posture-default-invite-only.ts:19`, and its generated mirror `packages/spec/src/migrations/registry.ts:6270`: "accepts forced email verification". This is the v18 migration's reason text, accurate for that release. It is `packages/spec` history, not live advice. **Read and left, because each is correct:** `audience-posture.ts:221` (it says "unless noted", then notes the `open` exception); `auth-manager.ts:1639` (it carries the #20389 exception); the runtime log at `auth-manager.ts:4818` ("… when email verification is forced on" is a sufficient condition, not a rule statement); `packages/spec/src/system/auth-config.zod.ts` lines 408–419 and 588; `content/docs/deployment/self-hosting.mdx:580` and its posture section; `content/docs/deployment/environment-variables.mdx:84`; and the `audience-posture*.test.ts` assertions, which pin the refusal messages as they stand. `auth-plugin.ts` has no hit, so nothing reaches the file #20412 holds. --- _Generated by [Claude Code](https://claude.ai/code/session_017B6YKCGu8CTY2KBWgwaHAs)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent c5d6b2b commit 87c37ae

5 files changed

Lines changed: 153 additions & 42 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
The `no_sign_in_account_at_boot` boot report states the email-verification rule each audience posture enforces.
6+
7+
Clause-②: no
8+
9+
The report's recovery advice said an `open` or `email_domain` posture forces email verification on the invited login. Later in the same message it said an `open` deployment can turn verification off. The first sentence stopped being true when posture `open` began honouring a deployment's opt-out. The message now states the rule once, as the auth plugin enforces it, and applies it to the invited login and to a new self-registered address alike:
10+
11+
- `email_domain` always forces email verification on.
12+
- `open` forces it on unless the deployment turns it off, with `emailAndPassword.requireEmailVerification: false` or `OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false`. A `false` stored only through the settings console is refused.
13+
- `invite_only` follows the deployment's declaration and is off by default.
14+
15+
The advice keeps its order: on a deployment with no mail transport, close the posture back to `invite_only`, with verification left at its default off, before the invited person registers.
16+
17+
Text only: no admission decision, verification default or accepted value changes.

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2292,10 +2292,12 @@ export class AuthManager {
22922292
// written — INSTEAD of throwing USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL.
22932293
//
22942294
// We turn that shield on ourselves: a posture that permits
2295-
// self-registration FORCES `requireEmailVerification` on (see
2296-
// `createAuthInstance`), so `email_domain` and `open` sign-ups for an
2297-
// address that already exists answered 200 while `invite_only`
2298-
// answered 422 on the same population. Measured on a real ObjectQL
2295+
// self-registration forces `requireEmailVerification` on by default
2296+
// (see `createAuthInstance` — always under `email_domain`, and under
2297+
// `open` unless the deployment declared it off, #20389), so
2298+
// `email_domain` and `open` sign-ups for an address that already
2299+
// exists answered 200 while `invite_only` answered 422 on the same
2300+
// population. Measured on a real ObjectQL
22992301
// engine with the posture held CONSTANT and only the verification flag
23002302
// moved, so the divergence is the flag's, not the posture's: zero
23012303
// inserts reach the engine, no `sys_account` appears, and the next

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

Lines changed: 96 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -150,18 +150,31 @@ describe('#14353 — the dead-end shape reports, by name, with consequence AND r
150150
it('[#15588/F1] the no-mail-transport rider is SCOPED to the default posture', () => {
151151
// ⛔ This rider was WRONG in the first version of this line, stated for
152152
// 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
153+
// bypass, so wherever verification is on the INVITED login is created and
154154
// then refused EMAIL_NOT_VERIFIED — and creating it SILENCES this report
155155
// on the way past. That is the loud-dead-end-to-quiet-dead-end transition
156156
// remedy (b) warns about, delivered by the primary remedy. The exact
157157
// scoped phrasing is asserted so no future edit can re-generalise it.
158158
const msg = resolveNoSignInAccountReport(DEAD_END)!;
159159
expect(msg).toContain('ADMISSION verdict, not a verification bypass');
160160
expect(msg).toContain(
161-
"under the default 'invite_only' posture no mail transport is needed either",
161+
"under the default 'invite_only' posture it follows that same declaration and is OFF by default, " +
162+
'so no mail transport is needed either',
162163
);
163164
// 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+
expect(msg).toContain('the INVITED login meets the same email-verification rule as any sign-up');
166+
// … and state that rule as the resolver ships it: `email_domain` forced
167+
// outright, `open` forced unless the DEPLOYMENT opts out, and the console
168+
// unable to make that opt-out. Each clause is driven against the wiring in
169+
// the `#20389` describe below.
170+
expect(msg).toContain("Under 'email_domain' verification is always ON");
171+
expect(msg).toContain("under 'open' it is ON unless the DEPLOYMENT has itself declared it off");
172+
expect(msg).toContain('a false stored only through the settings console is refused there');
173+
// [#20389] ⛔ The unconditional form this message carried before `open`
174+
// honoured a deployment's opt-out — it contradicted remedy (b) in the same
175+
// message. Either spelling coming back reds here.
176+
expect(msg).not.toMatch(/an 'open' or 'email_domain' posture forces email verification/i);
177+
expect(msg).not.toMatch(/every posture other than 'invite_only'[^.]*FORCES email verification/i);
165178
// … and give the operator the ORDER, which is the actionable half
166179
expect(msg).toContain("close the posture back to 'invite_only' BEFORE that person registers");
167180
// [#15588/N2] the lookup lowercases, so a mixed-case row is never found
@@ -246,14 +259,23 @@ const invitedSignUp = (posture: AudiencePosture, hasPendingInvitation: boolean)
246259
isBootstrap: false,
247260
});
248261

249-
/** A manager carrying one audience declaration, for the wiring mirror. */
262+
/**
263+
* A manager carrying one audience declaration, for the wiring mirror. The
264+
* optional `emailAndPassword` block is the DEPLOYMENT's own declaration — the
265+
* constructor config is the deployment's by definition (`assertAudienceConfig`
266+
* runs there with `verificationDeclaredBy: 'deployment'`).
267+
*/
250268
const AUDIENCE_TEST_SECRET = 'test-secret-at-least-32-chars-long!!';
251-
const managerWith = (audience?: Record<string, unknown>) =>
269+
const managerWith = (
270+
audience?: Record<string, unknown>,
271+
emailAndPassword?: Record<string, unknown>,
272+
) =>
252273
new AuthManager({
253274
secret: AUDIENCE_TEST_SECRET,
254275
baseUrl: 'http://localhost:3000',
255276
dataEngine: engineOver({}).engine,
256277
...(audience ? { audience } : {}),
278+
...(emailAndPassword ? { emailAndPassword } : {}),
257279
} as never);
258280

259281
/** The declaration a widening posture needs to pass entry validation. */
@@ -328,12 +350,14 @@ describe('#15588 — the WARNING is true: ANY hand-written `sys_account` row sil
328350
});
329351
});
330352

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.
353+
describe('#15588 — the WARNING is true: widening the posture forces email verification on BY DEFAULT', () => {
354+
it('with nothing declared, `email_domain` and `open` both wire requireEmailVerification ON', () => {
355+
// The wiring and `getPublicConfig()` both read
356+
// `resolveEmailVerificationRequirement`, so this is the same fact the
357+
// message reports: a login registered under a widened posture that has not
358+
// opted out cannot sign in until a mail transport delivers the link. The
359+
// `open` opt-out, and `email_domain` having none, are pinned in the
360+
// `#20389` describe below.
337361
for (const posture of ['email_domain', 'open'] as const) {
338362
expect(
339363
managerWith(wideningAudience(posture)).getPublicConfig().emailPassword
@@ -349,10 +373,11 @@ describe('#15588 — the WARNING is true: widening the posture FORCES email veri
349373
expect(managerWith().getPublicConfig().emailPassword.requireEmailVerification).toBe(false);
350374
});
351375

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.
376+
it('with nothing declared, exactly the postures that widen wire it ON — read off the vocabulary', () => {
377+
// The message states the verification rule posture by posture. A fourth
378+
// posture that did NOT permit self-registration yet forced verification
379+
// (or the reverse) would leave that rule incomplete, and this is where
380+
// that is caught.
356381
for (const posture of AUDIENCE_POSTURES) {
357382
expect(
358383
managerWith(
@@ -369,7 +394,7 @@ describe('#15588/F1 — the carve-out ADMITS, it does not EXEMPT: the rider\u201
369394
// mechanisms together — the carve-out and the verification wiring — because
370395
// the false claim was precisely that the first cancels the second.
371396

372-
it('a widened posture admits the INVITED creation AND still forces verification on it', () => {
397+
it('with nothing declared, a widened posture admits the INVITED creation AND still forces verification on it', () => {
373398
for (const posture of AUDIENCE_POSTURES.filter(audiencePermitsSelfRegistration)) {
374399
// admitted by the carve-out …
375400
expect(invitedSignUp(posture, true), `posture ${posture}`).toMatchObject({ admit: true });
@@ -382,7 +407,7 @@ describe('#15588/F1 — the carve-out ADMITS, it does not EXEMPT: the rider\u201
382407
}
383408
});
384409

385-
it('ONLY the default posture is mail-transport-free — the half the message still promises', () => {
410+
it('the default posture, verification undeclared, is mail-transport-free — the half the rider promises', () => {
386411
expect(invitedSignUp('invite_only', true)).toMatchObject({ admit: true });
387412
expect(managerWith().getPublicConfig().emailPassword.requireEmailVerification).toBe(false);
388413
});
@@ -399,6 +424,60 @@ describe('#15588/F1 — the carve-out ADMITS, it does not EXEMPT: the rider\u201
399424
});
400425
});
401426

427+
describe('#20389 — the rule the message states, driven against the wiring posture by posture', () => {
428+
// The message now says: "Under 'email_domain' verification is always ON;
429+
// under 'open' it is ON unless the DEPLOYMENT has itself declared it off
430+
// (… a false stored only through the settings console is refused there);
431+
// under the default 'invite_only' posture it follows that same declaration
432+
// and is OFF by default". Each clause below drives the manager that wires
433+
// it, so the day the rule moves, the sentence goes red with it.
434+
const DEPLOYMENT_OPT_OUT = { requireEmailVerification: false };
435+
436+
it("`open` + the DEPLOYMENT's explicit false: the invited creation is admitted AND wired with verification OFF", () => {
437+
expect(invitedSignUp('open', true)).toMatchObject({ admit: true });
438+
expect(
439+
managerWith(wideningAudience('open'), DEPLOYMENT_OPT_OUT).getPublicConfig().emailPassword
440+
.requireEmailVerification,
441+
).toBe(false);
442+
});
443+
444+
it('`open` + the same false stored only through the console: refused, and verification stays ON', () => {
445+
const fromConsole = managerWith(wideningAudience('open'));
446+
expect(() =>
447+
fromConsole.applyConfigPatch({ emailAndPassword: DEPLOYMENT_OPT_OUT } as never, {
448+
requireEmailVerificationFrom: 'console',
449+
}),
450+
).toThrow(/posture 'open' FORCES email verification on unless the DEPLOYMENT turns it off/);
451+
expect(fromConsole.getPublicConfig().emailPassword.requireEmailVerification).toBe(true);
452+
// CONTROL — the identical patch declared by the deployment is honoured, so
453+
// the refusal above is the declarant's, not the value's.
454+
const fromDeployment = managerWith(wideningAudience('open'));
455+
fromDeployment.applyConfigPatch({ emailAndPassword: DEPLOYMENT_OPT_OUT } as never, {
456+
requireEmailVerificationFrom: 'deployment',
457+
});
458+
expect(fromDeployment.getPublicConfig().emailPassword.requireEmailVerification).toBe(false);
459+
});
460+
461+
it('`email_domain` has no opt-out: the deployment’s explicit false refuses the boot', () => {
462+
expect(() => managerWith(wideningAudience('email_domain'), DEPLOYMENT_OPT_OUT)).toThrow(
463+
/posture 'email_domain' opens self-registration, which FORCES email verification on/,
464+
);
465+
// CONTROL — the same declaration without the false boots, verification ON.
466+
expect(
467+
managerWith(wideningAudience('email_domain')).getPublicConfig().emailPassword
468+
.requireEmailVerification,
469+
).toBe(true);
470+
});
471+
472+
it('`invite_only` follows the declaration: OFF by default, ON when the deployment declares it', () => {
473+
expect(managerWith().getPublicConfig().emailPassword.requireEmailVerification).toBe(false);
474+
expect(
475+
managerWith({ posture: 'invite_only' }, { requireEmailVerification: true }).getPublicConfig()
476+
.emailPassword.requireEmailVerification,
477+
).toBe(true);
478+
});
479+
});
480+
402481
describe('#15588 — remedy (b) cannot recover an EXISTING person, and the posture is not the lever', () => {
403482
it('under `open` the gate ADMITS the address outright — so the posture is not what stops them', () => {
404483
// The message says widening "cannot recover an EXISTING person at all …

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

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,20 @@
4444
* (`audience-posture.ts`, "The invitation carve-out") is admitted under
4545
* every posture in `AUDIENCE_POSTURES`. No door needs widening. ⚠️ But the
4646
* carve-out is an ADMISSION verdict and NOT a verification bypass, so the
47-
* third bullet below applies to the INVITED login too: only under the
48-
* default `invite_only` posture is the recovery mail-transport-free, and on
49-
* `open`/`email_domain` the invited login is created and then refused
50-
* `EMAIL_NOT_VERIFIED` — which also SILENCES this report, the very
51-
* transition the second bullet warns about, delivered by the primary
52-
* remedy. The message therefore scopes the no-mail-transport rider and
53-
* tells the operator to close the posture first; ⛔ never re-state that
54-
* rider for EVERY posture. Pinned across the whole posture vocabulary, with
47+
* INVITED login meets the same email-verification rule as any sign-up
48+
* (`resolveEmailVerificationRequirement`, the one resolver the wiring and
49+
* `getPublicConfig()` both read): always on under `email_domain`; on under
50+
* `open` unless the DEPLOYMENT declared it off (#20389 — a `false` stored
51+
* only through the settings console is refused there); and the declared
52+
* value, off by default, under `invite_only`. Wherever it is on, the
53+
* invited login is created and then refused `EMAIL_NOT_VERIFIED` — which
54+
* also SILENCES this report, the very transition the second bullet warns
55+
* about, delivered by the primary remedy. The message therefore states that
56+
* rule once, scopes the no-mail-transport rider to the default
57+
* `invite_only` posture, and tells the operator to close the posture first;
58+
* ⛔ never re-state that rider for EVERY posture, and ⛔ never state the
59+
* forcing for every non-`invite_only` posture either — `open` honours a
60+
* deployment's opt-out. Pinned across the whole posture vocabulary, with
5561
* the un-invited `invite_only` refusal as its control, and the scope itself
5662
* pinned beside it.
5763
* - **A hand-written credential row blinds this very check.**
@@ -474,12 +480,18 @@ export function resolveNoSignInAccountReport(
474480
'lowercases the address it searches for, so a mixed-case row is never found), then have that ' +
475481
'person register through the ordinary sign-up endpoint. The invitation carve-out admits that ' +
476482
'ONE creation under EVERY audience posture, so no door needs widening — but it is an ADMISSION ' +
477-
"verdict, not a verification bypass: under the default 'invite_only' posture no mail transport " +
478-
"is needed either, whereas an 'open' or 'email_domain' posture forces email verification on the " +
479-
"INVITED login too (see (b)), so close the posture back to 'invite_only' BEFORE that person " +
480-
"registers. On the 'single' tenancy posture that account holder is then promoted to platform " +
481-
'admin. Afterwards, re-run the provisioning job that seeded these people so it seeds their ' +
482-
'logins too. TWO THINGS THAT LOOK LIKE REMEDIES AND ARE NOT: (a) HAND-WRITING A CREDENTIAL ' +
483+
'verdict, not a verification bypass: the INVITED login meets the same email-verification rule as ' +
484+
"any sign-up. Under 'email_domain' verification is always ON; under 'open' it is ON unless the " +
485+
'DEPLOYMENT has itself declared it off (emailAndPassword.requireEmailVerification: false, or ' +
486+
'OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false — a false stored only through the settings console is ' +
487+
"refused there); under the default 'invite_only' posture it follows that same declaration and is " +
488+
'OFF by default, so no mail transport is needed either. Wherever it is ON, the invited login is ' +
489+
'refused EMAIL_NOT_VERIFIED at its first sign-in until a mail transport delivers the link, so on a ' +
490+
"deployment without one, close the posture back to 'invite_only' BEFORE that person registers " +
491+
"and leave verification at its default OFF. On the 'single' tenancy posture that account holder " +
492+
'is then promoted to platform admin. Afterwards, re-run the provisioning job that seeded these ' +
493+
'people so it seeds their logins too. TWO THINGS THAT LOOK LIKE REMEDIES AND ARE NOT: (a) ' +
494+
'HAND-WRITING A CREDENTIAL ' +
483495
`ROW — a '${SystemObjectName.ACCOUNT}' row's 'password' column must carry a secret in the ` +
484496
"platform's own hash format, so a plaintext password authenticates nothing — a 401 or a 500 " +
485497
'depending on the row shape, never a session — and ' +
@@ -488,11 +500,10 @@ export function resolveNoSignInAccountReport(
488500
'OPENING THE AUDIENCE POSTURE — which cannot recover an EXISTING person at all: ' +
489501
'self-registration is a user-CREATION path, so it cannot hand a login to somebody whose ' +
490502
`'${SystemObjectName.USER}' row already exists, and NO posture changes that. Widening only ever ` +
491-
"admits a NEW address — and then every posture other than 'invite_only' ('open', " +
492-
"'email_domain') FORCES email verification ON unless an 'open' deployment has itself declared it " +
493-
'off, so that login is refused EMAIL_NOT_VERIFIED at ' +
494-
'its first sign-in until a mail transport delivers the link, and a locked-out self-hosted ' +
495-
'install usually has none. Nothing here happens by itself.'
503+
'admits a NEW address — and that login meets the same verification rule stated above, so wherever ' +
504+
'it is ON that login is refused EMAIL_NOT_VERIFIED at its first sign-in until a mail transport ' +
505+
'delivers the link, and a locked-out self-hosted install usually has none. Nothing here happens ' +
506+
'by itself.'
496507
);
497508
}
498509

‎packages/plugins/plugin-auth/src/signup-existing-address-refusal.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@
2727
* - So no insert is attempted and nothing is swallowed. Case ① asserts that
2828
* directly by counting engine `insert` calls across the request.
2929
* - And the POSTURE is not the cause: it is only what turns the shield on. A
30-
* self-registration-permitting posture FORCES `requireEmailVerification`
31-
* (`createAuthInstance`). Case ⓪ holds the posture CONSTANT at the
30+
* self-registration-permitting posture forces `requireEmailVerification`
31+
* on by default (`createAuthInstance` — always under `email_domain`, and
32+
* under `open` unless the deployment declared it off, #20389). Case ⓪
33+
* holds the posture CONSTANT at the
3234
* `invite_only` default and moves only that flag — 422 becomes the
3335
* synthetic 200 — which is what makes "before the uniqueness refusal" a
3436
* measurement rather than a reading of vendor source.

0 commit comments

Comments
 (0)