Skip to content

Commit 65352b7

Browse files
hotlongclaude
andauthored
feat(plugin-auth): the open audience posture honours a deployment's email-verification opt-out (#20389) (#20406)
Fixes #20389 Clause-②: yes ## What this changes Under `audience.posture: 'open'`, an explicit `emailAndPassword.requireEmailVerification: false` that the **deployment** declares is now honoured instead of refused. Everything else keeps today's behaviour. | Declared by | `open` + explicit `false` | `email_domain` + explicit `false` | |:---|:---|:---| | Stack config (the `AuthManager` constructor) | **honoured** (was: boot refused) | refused, same message | | Host code calling `AuthManager.applyConfigPatch()` (default declarant) | **honoured** (was: refused) | refused, same message | | `OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false` (settings env override, source `env`) | **honoured** (was: refused) | refused, same message | | A value stored through the auth settings console (any other source) | refused, as today | refused, same message | `open` with the value absent or `true` still forces verification on. `invite_only` is unchanged. - `audience-posture.ts`: `assertAudienceConfig` keeps the `email_domain` refusal byte-identical, and under `open` refuses an explicit `false` only when the console declared it. A new `resolveEmailVerificationRequirement(posture, declared)` is the one function both the better-auth wiring (`createAuthInstance`) and `getPublicConfig()` read, so the advertisement cannot disagree with the behaviour. - `auth-manager.ts`: tracks one bit, whether the deployment declared verification off. The constructor sets it. A patch whose `requireEmailVerification` comes from the deployment re-declares it, and a console patch leaves it as it stood. So the console can agree with the deployment's opt-out but never make one. `applyConfigPatch` gains an optional `{ requireEmailVerificationFrom: 'deployment' | 'console' }`, which defaults to `deployment`. - `auth-plugin.ts`: the settings binding passes `deployment` when `require_email_verification` arrives with source `env`, and `console` otherwise. At `kernel:ready`, after the settings namespace is applied, the plugin logs **one** `warn` when the posture is `open` and the advertised requirement is `false`. The warning names the posture and the consequence: anyone can register an address they do not control, and an organization invitation sent to that address can then be accepted by that account. - Spec `auth-config.zod.ts`: the `audience` docblocks and the `posture` description now say what the code does. `content/docs/references/system/auth-config.mdx` is regenerated with `gen:schema` and `gen:docs`, not hand-edited. `self-hosting.mdx` gains "Email verification under each posture", and `environment-variables.mdx` describes the variable. The recovery clause in `boot-sign-in-reachability.ts` that must agree with that page gets the same qualifier. ## Premise correction: the settings door carries the maintainer's variable The dispatch's working assumption was that the settings path is runtime-only and should keep refusing. Measured, it is not runtime-only. In this framework `OS_AUTH_REQUIRE_EMAIL_VERIFICATION` is the settings env override for `auth.require_email_verification`, so it reaches the manager through `bindAuthSettings` and then `applyConfigPatch`, not through the constructor. The settings service reports it as `source: 'env'`, locked. On `main` at `db74b169dc`, I booted with a stack-config `open` posture, that env override set to `false`, and a sibling `session_expiry_days: 3`. The probe lives only in local rig logs. Results: - The env value was refused. - The sibling setting was dropped too: `session.expiresIn` read `undefined`. The only trace was `warn "Auth: failed to apply auth settings: [audience] invalid audience configuration: posture 'open' … FORCES email verification on …"`. - The same boot logged the `error` "email verification is REQUIRED but NO email service is registered … or disable verification (OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false)". That remedy was the very value being refused. The cloud control plane mounts `SettingsServicePlugin`, so a host that maps the variable into stack config also receives it through this second door. If the patch path had kept refusing, every auth settings pass on such a host would have been dropped. So the switch is keyed on who declared the value, not on which method received it: the constructor and the env override are the deployment, and a stored console value is not. The fork, with options and a recommendation, is in the `os-dev-report` on the card. ## In-place fix (same defect class, same file, same gates) The no-mail-transport boot `error` in `auth-plugin.ts` prescribed `OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false` under every posture. Under `email_domain` that value is refused, so the error now says verification cannot be turned off under that posture. It still offers the variable where it works. It is pinned by `audience-posture-setting.test.ts` ("…and under email_domain, where it is refused, the error does not offer it"). ## Tests Pins flipped to the new behaviour, not deleted: - `audience-posture.test.ts`: - the old "refuses the open-posture-with-verification-off contradiction" became an `open` test: honoured from the deployment, refused from the console, with the new message's first sentence asserted; - an `email_domain` test asserts the refusal's message family for all three declarants; - the `applyConfigPatch` tail now drives the console declarant, and three new patch tests cover console agreement, opening the posture over a console-stored `false`, and `email_domain` from the deployment; - the constructor boots `open` + `false` and still refuses `email_domain` + `false`. - End of chain, a real better-auth pipeline over the memory engine: `open` + deployment `false` returns a sign-up with a session `token` and `user.emailVerified: false`, and the public config reports `false`. Its control, `open` with the value undeclared, still mints no session. - `audience-posture-setting.test.ts` (the settings channel): - the console-stored `false` under `open` stays refused. This pin is deliberately not flipped, because it is the console door; - the env-override `false` under `open` is honoured, with exactly one warning, and a second settings pass does not warn again; - the stack config and the env override together agree, and the sibling `session_expiry_days` still applies; - the env override `false` under `email_domain` is refused with the unchanged message; - two controls produce no warning: `open` with verification on, and `invite_only` with it off. Ablations, one at a time. Each mutation went through `scripts/ablation-replace.mjs`, which checked the anchor count and blob on disk and restored to the `HEAD` blob with an empty `git diff HEAD`. Tests import `src` directly, so no build was involved. - A1: the console-door refusal replaced with `if (false)` turned 5 tests red, 66 green. - A2: the resolver's `open` branch forced to `true` turned 5 tests red, 66 green. - A3: the plugin's env-to-deployment mapping replaced with a constant `console` turned 1 test red (the env-only door), 70 green. The dual-door test stays green under A3 by design: the stack-config opt-out already stands, so the console-marked `false` is agreement. ## Verification (final head `e2d18233c9`) - `pnpm --filter @objectstack/plugin-auth test`: 114 files, 2454 tests passed. - `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0, including `check:test-typecheck`. The debt is held at 10 files, 94 errors and 23 signatures. - `pnpm --filter @objectstack/plugin-auth build`: exit 0. A full `turbo run build` over `./packages/*` and `./packages/*/*` had 71 of 71 tasks succeed at this head. - `pnpm --filter @objectstack/spec build` and `typecheck`: exit 0. - Spec tests: - The whole `@objectstack/spec` suite passed 17097 tests and failed 4, in 3 files. - Re-run alone, `scoped-context.test.ts` passed; its 2 failures were 60 s timeouts under load. - The other 2 still fail with `packages/spec/src/system/auth-config.zod.ts` restored to base bytes, so they are not this diff. They are `build-schemas-check-mode` "writes the generation stamp…", which reads `mismatch`, and the `check-liveness` "drill ceiling" test, which fails on a JSON string truncated at 65478 bytes. The restore was trap-guarded and hash-verified. - Gates: `dispatch-gates.mjs --commands` derived 113 from the merge base, and `--ran` reconciled 113, with 112 run, 1 NOT MEASURED and 0 unrun. - `check:generated` is NOT MEASURED. This host's pnpm 10.31.0 refuses the wrapper's inner `pnpm -s SCRIPT` spelling ("unexpected argument '-s'") for all 15 member gates, so the wrapper never reached a gate body. Each member gate ran directly as `pnpm run check:NAME` and exited 0. `check:docs` was the one stale artifact, fixed by regenerating. - `check:merge-driver` exits 1. Its inputs (`scripts/`, `.githooks/`, `.gitattributes`) are byte-identical to base, and `node scripts/git-merge-regen.mjs --self-test` fails identically on a detached base checkout, at the same "FIRING CONTROL" leg. It is a host fact, not this diff. - `check:skill-examples`, `check:dual-build-cjs-loads` and `check:type-check-debt` first refused on a missing `dist`. Each exited 0 after the build. - Every other derived gate exited 0. - Lint, as a measured narrowing: `pnpm exec eslint --no-inline-config --format json` over the diff's 7 `.ts` files reported 7 files, 0 errors and 0 warnings. The population is read from `eslint.config.mjs`: its `**/*.{ts,…}` blocks, while `.md` and `.mdx` are not linted. The config never enables type-aware linting (its own comment at lines 327–328), so this diff cannot move a verdict on an untouched file. - Declared: `scripts/pm/os-verify-lock.sh` ran in **UNLOCKED (declared)** mode for every build and test (no usable `flock` on this macOS host). - Declared narrowing: `main` was not merged in before opening. The five commits `origin/main` gained since base touch none of this diff's paths (`git diff --stat` base..`origin/main` over them is empty, and `git merge-tree` is clean). The PR merge ref and the queue validate the joint result. ## Acceptance notes - The console help text still reads "Postures other than invitation-only force email verification on". It lives in `packages/services/service-settings/src/manifests/auth.manifest.ts`, the `audience` group and the `audience_posture` help, and in its `en` and translated bundles. From the console's side it stays true, because the console cannot make the opt-out. On a deployment with the env override set, the locked `false` row sits beside it. That file is outside this card's declared file surface, so it is left for a follow-up. - `docs/qa/platform-checklist/areas/identity-auth.json` still states "on a permitting posture, requireEmailVerification is FORCED on" as a coverage boundary. It is outside the file surface and noted only. - Invitation behaviour is deliberately unchanged. `requireEmailVerificationOnInvitation: false` is set unconditionally today. Under the default `invite_only` posture, where verification is off by default, the invitation carve-out already lets whoever registers an invited address accept that invitation. The `open` opt-out widens that window to addresses registered before an invitation exists. The warning and the docs name this consequence. --- _Generated by [Claude Code](https://claude.ai/code/session_d8bf7d34-4f31-407d-ac85-6cb849eec4bf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7db1332 commit 65352b7

11 files changed

Lines changed: 537 additions & 72 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
'@objectstack/spec': patch
4+
---
5+
6+
**plugin-auth: under the `open` audience posture, the deployment can turn email verification off**
7+
8+
Clause-②: yes
9+
10+
Under `audience.posture: 'open'`, an explicit `emailAndPassword.requireEmailVerification: false`
11+
declared by the **deployment** is now honoured instead of refused at config entry. The deployment
12+
declares it through its stack config (the `AuthManager` constructor), host code calling
13+
`AuthManager.applyConfigPatch()`, or the `OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false` env override
14+
of the `auth.require_email_verification` setting. A sign-up is then signed in at once, with no
15+
verification mail. This is for a deployment with no mail transport that trusts its sign-ups,
16+
such as a pre-production environment, which otherwise dead-ends every new account at the verify
17+
page.
18+
19+
Nothing changes for anyone who does not opt out:
20+
21+
- `open` with the value absent or `true` still forces verification on.
22+
- `email_domain` still refuses an explicit `false`, from any source, with the same message. The
23+
domain allowlist is the only gate there, so an unverified sign-up could claim a colleague's
24+
address.
25+
- `invite_only` is unchanged.
26+
- A `false` stored only through the settings console is still refused under `open`. The console
27+
can agree with the deployment's opt-out, never make one.
28+
29+
The opt-out is loud. `AuthPlugin` logs one warning at boot naming the posture and the
30+
consequence: anyone can register an address they do not control, and an organization invitation
31+
sent to that address can then be accepted by that account. `getPublicConfig()` reports
32+
`requireEmailVerification: false`, the value actually wired, because the wiring and the
33+
advertisement now read one resolver.
34+
35+
`AuthManager.applyConfigPatch()` takes an optional second argument,
36+
`{ requireEmailVerificationFrom: 'deployment' | 'console' }`. It defaults to `deployment`; the
37+
settings binding passes `console` for a stored value and `deployment` for an env override.

‎content/docs/deployment/environment-variables.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
8181
| `OS_DISABLE_SIGNUP` | boolean | `false` | When `true`, block new email/password sign-ups. Under the `single` posture the very first user can still sign up to bootstrap admin; under the walled postures no sign-up is ever promoted, so this leaves the deployment dependent on `OS_PLATFORM_OWNER_EMAIL` alone. |
8282
| `OS_AUTH_EMAIL_PASSWORD_ENABLED` | boolean | settings default | Settings env override for `auth.email_password_enabled`. Controls local email/password login. |
8383
| `OS_AUTH_SIGNUP_ENABLED` | boolean | settings default | Settings env override for `auth.signup_enabled`. Takes precedence over UI settings and is preferred over `OS_DISABLE_SIGNUP`. |
84-
| `OS_AUTH_REQUIRE_EMAIL_VERIFICATION` | boolean | settings default | Settings env override for `auth.require_email_verification`. |
84+
| `OS_AUTH_REQUIRE_EMAIL_VERIFICATION` | boolean | settings default | Settings env override for `auth.require_email_verification`. Under the `open` audience posture, `false` is the deployment's opt-out from the forced verification (one boot warning; see [Email verification under each posture](/docs/deployment/self-hosting#email-verification-under-each-posture)); under `email_domain` it is refused, since verification is forced there. |
8585
| `OS_AUTH_MEMBERSHIP_POLICY` | `auto` \| `invite-only` | `auto` | Settings env override for `auth.membership_policy` — what a newly created user joins (ADR-0093 D1). `auto` binds every new user to the deployment's default organization. `invite-only` grants membership solely through an explicit act: creating a workspace, accepting an invitation, an admin adding them, or SSO just-in-time provisioning. Applies to sign-up **and** to the backfill of pre-existing member-less users. An unrecognized value is rejected with an `error` log and **ignored** — the deployment keeps its current policy rather than silently reverting to `auto`. |
8686
| `OS_AUTH_GOOGLE_ENABLED` | boolean | settings default | Settings env override for `auth.google_enabled`. Requires Google OAuth credentials from Settings or env. |
8787
| `GOOGLE_CLIENT_ID` | string | — | Deployment-level Google OAuth client id for the open-source Google login implementation. |

‎content/docs/deployment/self-hosting.mdx‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -577,8 +577,11 @@ this step.
577577
answer the `invite_only` default already gives for an address the directory
578578
holds, so the widened posture buys that person no login and their sign-in
579579
stays `401`. A fresh address does get an account, but every posture other
580-
than `invite_only` forces email verification on, so its first sign-in is
581-
refused `403 EMAIL_NOT_VERIFIED` until a mail transport delivers the link.
580+
than `invite_only` forces email verification on (unless an `open` deployment
581+
has itself declared it off — see
582+
[Email verification under each posture](#email-verification-under-each-posture)),
583+
so its first sign-in is refused `403 EMAIL_NOT_VERIFIED` until a mail
584+
transport delivers the link.
582585
- **A hand-written credential row authenticates nothing.** The `sys_account`
583586
row shape is public; the format of the secret stored in its `password`
584587
column is the platform's own. A row carrying a plaintext password is refused
@@ -633,6 +636,30 @@ remove. If you run a real internet-facing `open` deployment and want that
633636
trade, open an issue saying so: it is a per-deployment decision, waiting on a
634637
deployment that needs it.
635638

639+
### Email verification under each posture
640+
641+
- **`invite_only`** follows `auth.require_email_verification`
642+
(`OS_AUTH_REQUIRE_EMAIL_VERIFICATION`), off by default.
643+
- **`email_domain`** forces verification on, and an explicit `false` is
644+
refused: the domain allowlist is the only gate there, so an unverified
645+
sign-up could claim a colleague's address.
646+
- **`open`** forces verification on too, **unless the deployment turns it
647+
off**: set `OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false`, or
648+
`emailAndPassword.requireEmailVerification: false` in the stack config. A
649+
sign-up is then signed in at once and no verification mail is sent.
650+
651+
The `open` opt-out is for a deployment with no mail transport that trusts
652+
whoever can reach it, such as a pre-production environment. It is loud and it
653+
has a cost:
654+
655+
- The boot logs one warning naming the posture and the consequence, and
656+
`/api/v1/auth/config` reports `requireEmailVerification: false` — the value
657+
actually wired.
658+
- Anyone can register an address they do not control. An organization
659+
invitation sent to that address can then be accepted by that account.
660+
- It is the deployment's to declare. A `false` stored only through the
661+
settings console is refused under `open`, and verification stays on.
662+
636663
## Go-live
637664

638665
Before pointing real users at the deployment, walk the

‎content/docs/references/system/auth-config.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ Advanced / low-level Better-Auth options
5151

5252
| Property | Type | Required | Description |
5353
| :--- | :--- | :--- | :--- |
54-
| **posture** | `Enum<'invite_only' \| 'email_domain' \| 'open'>` | optional (default: `"invite_only"`) | Who may self-register into this environment: invite_only (default — operator acts only), email_domain (allowlisted email domains), or open (anyone). Any posture other than invite_only forces email verification on. |
54+
| **posture** | `Enum<'invite_only' \| 'email_domain' \| 'open'>` | optional (default: `"invite_only"`) | Who may self-register into this environment: invite_only (default — operator acts only), email_domain (allowlisted email domains), or open (anyone). email_domain forces email verification on; open forces it on unless the deployment explicitly sets emailAndPassword.requireEmailVerification to false. |
5555
| **allowedEmailDomains** | `string[]` | optional | Email domains admitted to self-register under posture email_domain (exact, case-insensitive match; subdomains need their own entries). Required non-empty for email_domain; refused under other postures. |
5656
| **selfRegistrationPermissionSet** | `string` | optional | sys_permission_set name granted to each self-registrant. Required when posture is email_domain or open; refused for invite_only. admin_full_access is refused. |
5757

@@ -168,7 +168,7 @@ OIDC / Generic OAuth2 provider configuration for enterprise SSO
168168

169169
| Property | Type | Required | Description |
170170
| :--- | :--- | :--- | :--- |
171-
| **posture** | `Enum<'invite_only' \| 'email_domain' \| 'open'>` | optional (default: `"invite_only"`) | Who may self-register into this environment: invite_only (default — operator acts only), email_domain (allowlisted email domains), or open (anyone). Any posture other than invite_only forces email verification on. |
171+
| **posture** | `Enum<'invite_only' \| 'email_domain' \| 'open'>` | optional (default: `"invite_only"`) | Who may self-register into this environment: invite_only (default — operator acts only), email_domain (allowlisted email domains), or open (anyone). email_domain forces email verification on; open forces it on unless the deployment explicitly sets emailAndPassword.requireEmailVerification to false. |
172172
| **allowedEmailDomains** | `string[]` | optional | Email domains admitted to self-register under posture email_domain (exact, case-insensitive match; subdomains need their own entries). Required non-empty for email_domain; refused under other postures. |
173173
| **selfRegistrationPermissionSet** | `string` | optional | sys_permission_set name granted to each self-registrant. Required when posture is email_domain or open; refused for invite_only. admin_full_access is refused. |
174174

‎packages/plugins/plugin-auth/src/audience-posture-setting.test.ts‎

Lines changed: 113 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@
1818
*
1919
* - empty domain list under `email_domain`;
2020
* - missing / forbidden (`admin_full_access`) self-registration permission set;
21-
* - posture ≠ `invite_only` with verification explicitly off.
21+
* - `email_domain` with verification explicitly off (any source), and `open`
22+
* with verification off stored only through the console (#20389: the
23+
* `OS_AUTH_REQUIRE_EMAIL_VERIFICATION` env override is the deployment's
24+
* opt-out and is honoured under `open`).
2225
*
2326
* Envelope note: refusals at THIS seam are log-line refusals (the binding runs
2427
* inside `applySettings`, not on an HTTP surface), so the assertions pin the
@@ -35,6 +38,7 @@ import type { PluginContext } from '@objectstack/core';
3538
import { AUDIENCE_POSTURES } from '@objectstack/spec/system';
3639
import { AuthPlugin } from './auth-plugin.js';
3740
import { AuthManager } from './auth-manager.js';
41+
import { OPEN_POSTURE_VERIFICATION_OFF_WARNING } from './audience-posture.js';
3842
import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/objectql';
3943

4044
const SECRET = 'test-secret-at-least-32-chars-long';
@@ -122,6 +126,8 @@ describe('auth.audience_* — the settings switch surface (#11768)', () => {
122126

123127
const errorLines = () =>
124128
(mockContext.logger.error as any).mock.calls.map((c: any[]) => String(c[0]));
129+
const warnLines = () =>
130+
(mockContext.logger.warn as any).mock.calls.map((c: any[]) => String(c[0]));
125131

126132
// ── 1. The declaration reaches the runtime, whole, through ONE patch ─────
127133

@@ -226,23 +232,125 @@ describe('auth.audience_* — the settings switch surface (#11768)', () => {
226232
expect(errorLines().some((m: string) => m.includes('admin_full_access'))).toBe(true);
227233
});
228234

229-
it('a self-registration posture with verification explicitly OFF is refused', async () => {
235+
it('open with verification OFF stored only through the CONSOLE is refused — the console cannot make the opt-out', async () => {
230236
// The audience patch is applied AFTER the main patch, so the merged-result
231237
// validation judges it against the `require_email_verification: false`
232-
// this same pass just applied — the #11739 "verification forced when
233-
// posture permits self-registration" invariant, held through the new door.
238+
// this same pass just applied. A console-stored value (source `global`) is
239+
// not the deployment's declaration, so under `open` it stays the #11739
240+
// contradiction (#20389 honours only the deployment's opt-out).
234241
const { manager } = await boot({
235242
settings: {
236243
require_email_verification: { value: false, source: 'global' },
237244
audience_posture: { value: 'open', source: 'global' },
238245
audience_self_registration_permission_set: { value: 'member_default', source: 'global' },
239246
},
240247
});
241-
// The main patch itself applied…
248+
// The main patch itself applied (legal under the standing invite_only)…
242249
expect((manager as any).config.emailAndPassword?.requireEmailVerification).toBe(false);
243250
// …and the audience that contradicts it was refused: standing rules.
244251
expect(manager.getAudience().posture).toBe('invite_only');
245252
expect(errorLines().some((m: string) => m.includes('requireEmailVerification'))).toBe(true);
253+
expect(errorLines().some((m: string) => m.includes('unless the DEPLOYMENT turns it off'))).toBe(true);
254+
expect(warnLines()).not.toContain(OPEN_POSTURE_VERIFICATION_OFF_WARNING);
255+
});
256+
257+
it('open with the OS_AUTH_REQUIRE_EMAIL_VERIFICATION env override OFF is honoured — the deployment declared it', async () => {
258+
// Every key arrives as an env override (source `env`), the shape a
259+
// deployment that configures itself only through OS_AUTH_* produces.
260+
const { manager } = await boot({
261+
settings: {
262+
require_email_verification: { value: false, source: 'env' },
263+
audience_posture: { value: 'open', source: 'env' },
264+
audience_self_registration_permission_set: { value: 'member_default', source: 'env' },
265+
},
266+
});
267+
expect(manager.getAudience().posture).toBe('open');
268+
expect(manager.getPublicConfig().emailPassword.requireEmailVerification).toBe(false);
269+
expect(errorLines().filter((m: string) => /audience|verification/i.test(m))).toEqual([]);
270+
// Loud: exactly one boot warning naming the posture and the consequence.
271+
expect(warnLines().filter((m: string) => m === OPEN_POSTURE_VERIFICATION_OFF_WARNING)).toHaveLength(1);
272+
expect(OPEN_POSTURE_VERIFICATION_OFF_WARNING).toContain("audience posture 'open'");
273+
expect(OPEN_POSTURE_VERIFICATION_OFF_WARNING).toContain('invitation');
274+
// A later settings pass re-applies the same declaration without a second warning.
275+
for (const cb of subscribers) cb();
276+
await vi.waitFor(() => expect(manager.getAudience().posture).toBe('open'));
277+
expect(warnLines().filter((m: string) => m === OPEN_POSTURE_VERIFICATION_OFF_WARNING)).toHaveLength(1);
278+
});
279+
280+
it('stack-config open + false AND the same env override: both doors agree, and sibling settings still apply', async () => {
281+
// A host that maps the env var into its stack config (the constructor
282+
// door) while the settings service ALSO carries it as an env override:
283+
// the second door must agree, never refuse the pass — a refused main
284+
// patch drops every sibling auth setting in it.
285+
const { manager } = await boot({
286+
pluginOptions: {
287+
audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' },
288+
emailAndPassword: { requireEmailVerification: false },
289+
},
290+
settings: {
291+
require_email_verification: { value: false, source: 'env' },
292+
session_expiry_days: { value: 3, source: 'global' },
293+
},
294+
});
295+
expect(manager.getAudience().posture).toBe('open');
296+
expect(manager.getPublicConfig().emailPassword.requireEmailVerification).toBe(false);
297+
expect((manager as any).config.session?.expiresIn).toBe(3 * 86_400);
298+
expect(warnLines().some((m: string) => m.includes('failed to apply auth settings'))).toBe(false);
299+
expect(warnLines().filter((m: string) => m === OPEN_POSTURE_VERIFICATION_OFF_WARNING)).toHaveLength(1);
300+
});
301+
302+
it('email_domain with the env override OFF is still refused — no source can turn verification off there', async () => {
303+
const { manager } = await boot({
304+
settings: {
305+
require_email_verification: { value: false, source: 'env' },
306+
audience_posture: { value: 'email_domain', source: 'env' },
307+
audience_allowed_email_domains: { value: 'acme.com', source: 'env' },
308+
audience_self_registration_permission_set: { value: 'member_default', source: 'env' },
309+
},
310+
});
311+
expect(manager.getAudience().posture).toBe('invite_only'); // refused: standing rules
312+
expect(
313+
errorLines().some((m: string) =>
314+
m.includes("posture 'email_domain' opens self-registration, which FORCES email verification on"),
315+
),
316+
).toBe(true);
317+
expect(warnLines()).not.toContain(OPEN_POSTURE_VERIFICATION_OFF_WARNING);
318+
});
319+
320+
it('the no-mail-transport boot error offers the env opt-out only where the posture honours it', async () => {
321+
// The harness registers no email service, so a REQUIRED verification
322+
// reports at boot. Under `open` turning it off is a real remedy now…
323+
await boot({
324+
pluginOptions: { audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' } },
325+
});
326+
const openLine = errorLines().find((m: string) => m.includes('NO email service is registered'));
327+
expect(openLine).toContain('OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false');
328+
});
329+
330+
it('…and under email_domain, where it is refused, the error does not offer it', async () => {
331+
await boot({
332+
pluginOptions: {
333+
audience: { posture: 'email_domain', allowedEmailDomains: ['acme.com'], selfRegistrationPermissionSet: 'member_default' },
334+
},
335+
});
336+
const line = errorLines().find((m: string) => m.includes('NO email service is registered'));
337+
expect(line).toBeDefined();
338+
expect(line).not.toContain('OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false');
339+
expect(line).toContain("cannot be turned off under the 'email_domain' audience posture");
340+
});
341+
342+
it('CONTROL — no opt-out warning when open keeps verification on', async () => {
343+
const { manager } = await boot({
344+
pluginOptions: { audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' } },
345+
});
346+
expect(manager.getPublicConfig().emailPassword.requireEmailVerification).toBe(true);
347+
expect(warnLines()).not.toContain(OPEN_POSTURE_VERIFICATION_OFF_WARNING);
348+
});
349+
350+
it('CONTROL — no opt-out warning when invite_only turns verification off (nothing forced it there)', async () => {
351+
const { manager } = await boot({ pluginOptions: { emailAndPassword: { requireEmailVerification: false } } });
352+
expect(manager.getPublicConfig().emailPassword.requireEmailVerification).toBe(false);
353+
expect(warnLines()).not.toContain(OPEN_POSTURE_VERIFICATION_OFF_WARNING);
246354
});
247355

248356
// ── 5. Composition: posture anchors the declaration ──────────────────────

0 commit comments

Comments
 (0)