You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 65352b7
Browse filesBrowse the repository at this point in the historyBrowse files
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>
|`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. |
82
82
|`OS_AUTH_EMAIL_PASSWORD_ENABLED`| boolean | settings default | Settings env override for `auth.email_password_enabled`. Controls local email/password login. |
83
83
|`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`. |
|`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. |
85
85
|`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`. |
86
86
|`OS_AUTH_GOOGLE_ENABLED`| boolean | settings default | Settings env override for `auth.google_enabled`. Requires Google OAuth credentials from Settings or env. |
87
87
|`GOOGLE_CLIENT_ID`| string | — | Deployment-level Google OAuth client id for the open-source Google login implementation. |
|**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. |
55
55
|**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. |
56
56
|**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. |
|**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. |
172
172
|**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. |
173
173
|**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. |
0 commit comments