Repository navigation
feat(plugin-auth): the open audience posture honours a deployment's email-verification opt-out (#20389) - #20406
Conversation
…under the open audience posture Under posture `open`, an explicit `emailAndPassword.requireEmailVerification: false` declared by the deployment (constructor config, host code, or the OS_AUTH_REQUIRE_EMAIL_VERIFICATION env override) is honoured instead of refused. `email_domain` keeps refusing it. A value only stored through the settings console still cannot turn verification off under `open`. The wiring and getPublicConfig() read one resolver, and the plugin logs one boot warning naming the posture and the consequence. Claude-Session: https://claude.ai/code/session_d8bf7d34-4f31-407d-ac85-6cb849eec4bf Co-authored-by: Claude <noreply@anthropic.com>
… door; amend the spec docblock and docs Flip the open-posture refusal pins to the new behaviour (constructor, applyConfigPatch from the deployment, the env-override settings door), keep the email_domain refusal asserted on its message family, and keep the console-stored door refused. Add the end-of-chain sign-up that is signed in, its forced-on control, and the once-per-boot warning. Claude-Session: https://claude.ai/code/session_d8bf7d34-4f31-407d-ac85-6cb849eec4bf Co-authored-by: Claude <noreply@anthropic.com>
…ure description Claude-Session: https://claude.ai/code/session_d8bf7d34-4f31-407d-ac85-6cb849eec4bf Co-authored-by: Claude <noreply@anthropic.com>
…ecords it Claude-Session: https://claude.ai/code/session_d8bf7d34-4f31-407d-ac85-6cb849eec4bf Co-authored-by: Claude <noreply@anthropic.com>
…ation opt-out only where the posture honours it Under email_domain the remedy "OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false" is refused, so the error names that instead of prescribing it. Claude-Session: https://claude.ai/code/session_d8bf7d34-4f31-407d-ac85-6cb849eec4bf Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 140 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 29219d95a5aa07c543bab721357562b7b9de4b61 && git checkout 29219d95a5aa07c543bab721357562b7b9de4b61
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dcd3bceaa068fc3cfb589bd6e04cd0b89b660580 e2d18233c99e2bfbd4067aea327801aa8d14afe5 && git checkout -B drift-repro dcd3bceaa068fc3cfb589bd6e04cd0b89b660580 && git merge --no-ff e2d18233c99e2bfbd4067aea327801aa8d14afe5
node scripts/docs-audit/affected-docs.mjs --json dcd3bceaa068fc3cfb589bd6e04cd0b89b660580
|
Contract reviewServed-tier: ① Derived judgmentsInputs: card #20389 body and its 3 comments (claim, os-dev-report, ACCEPT); PR #20406 body, 11-file list, and the net diff Accept-set changes (config entry,
Public-surface changes:
Gates on the head: every derived family is a success check-run — Check Changeset (both triggers), Part-of PR must not also close its card, The card this PR closes must claim this branch, single-writer path, Lint & Repo Gates, Test Core 1-6, the five Type Check lanes, Build Core, Build Docs, Check Documentation Links, Spec property liveness, Dogfood Regression Gate 1-3 and Verify CLI, Temporal Conformance, Governed Surface Queue Guard, Flag docs affected. Skipped: Console Pin Gate, Packed-tarball smoke (opt-in), and the duplicate Auto Label / Check PR Size triggers — none is a verdict on this diff. ② Semver levelChangeset ③ Boundary flagsDev deviations:
Dev open questions:
Out-of-scope findings:
Observation, within the ruling, not escalated: the warning is boot-time as ruled. A deployment that declares Implemented-by: VERDICT: PASS |
…-verification rule as shipped (objectstack-ai#20434) Fixes objectstack-ai#20413 Clause-②: no Round 2 of the card, location 4: the plugin-auth boot report. Round 1 (PR objectstack-ai#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`, objectstack-ai#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 `objectstack-ai#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 objectstack-ai#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 `[objectstack-ai#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 objectstack-ai#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 objectstack-ai#20412 holds. --- _Generated by [Claude Code](https://claude.ai/code/session_017B6YKCGu8CTY2KBWgwaHAs)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20389
Clause-②: yes
What this changes
Under
audience.posture: 'open', an explicitemailAndPassword.requireEmailVerification: falsethat the deployment declares is now honoured instead of refused. Everything else keeps today's behaviour.open+ explicitfalseemail_domain+ explicitfalseAuthManagerconstructor)AuthManager.applyConfigPatch()(default declarant)OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false(settings env override, sourceenv)openwith the value absent ortruestill forces verification on.invite_onlyis unchanged.audience-posture.ts:assertAudienceConfigkeeps theemail_domainrefusal byte-identical, and underopenrefuses an explicitfalseonly when the console declared it. A newresolveEmailVerificationRequirement(posture, declared)is the one function both the better-auth wiring (createAuthInstance) andgetPublicConfig()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 whoserequireEmailVerificationcomes 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.applyConfigPatchgains an optional{ requireEmailVerificationFrom: 'deployment' | 'console' }, which defaults todeployment.auth-plugin.ts: the settings binding passesdeploymentwhenrequire_email_verificationarrives with sourceenv, andconsoleotherwise. Atkernel:ready, after the settings namespace is applied, the plugin logs onewarnwhen the posture isopenand the advertised requirement isfalse. 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.auth-config.zod.ts: theaudiencedocblocks and theposturedescription now say what the code does.content/docs/references/system/auth-config.mdxis regenerated withgen:schemaandgen:docs, not hand-edited.self-hosting.mdxgains "Email verification under each posture", andenvironment-variables.mdxdescribes the variable. The recovery clause inboot-sign-in-reachability.tsthat 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_VERIFICATIONis the settings env override forauth.require_email_verification, so it reaches the manager throughbindAuthSettingsand thenapplyConfigPatch, not through the constructor. The settings service reports it assource: 'env', locked.On
mainatdb74b169dc, I booted with a stack-configopenposture, that env override set tofalse, and a siblingsession_expiry_days: 3. The probe lives only in local rig logs. Results:session.expiresInreadundefined. The only trace waswarn "Auth: failed to apply auth settings: [audience] invalid audience configuration: posture 'open' … FORCES email verification on …".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 theos-dev-reporton the card.In-place fix (same defect class, same file, same gates)
The no-mail-transport boot
errorinauth-plugin.tsprescribedOS_AUTH_REQUIRE_EMAIL_VERIFICATION=falseunder every posture. Underemail_domainthat 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 byaudience-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:opentest: honoured from the deployment, refused from the console, with the new message's first sentence asserted;email_domaintest asserts the refusal's message family for all three declarants;applyConfigPatchtail now drives the console declarant, and three new patch tests cover console agreement, opening the posture over a console-storedfalse, andemail_domainfrom the deployment;open+falseand still refusesemail_domain+false.open+ deploymentfalsereturns a sign-up with a sessiontokenanduser.emailVerified: false, and the public config reportsfalse. Its control,openwith the value undeclared, still mints no session.audience-posture-setting.test.ts(the settings channel):falseunderopenstays refused. This pin is deliberately not flipped, because it is the console door;falseunderopenis honoured, with exactly one warning, and a second settings pass does not warn again;session_expiry_daysstill applies;falseunderemail_domainis refused with the unchanged message;openwith verification on, andinvite_onlywith 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 theHEADblob with an emptygit diff HEAD. Tests importsrcdirectly, so no build was involved.if (false)turned 5 tests red, 66 green.openbranch forced totrueturned 5 tests red, 66 green.consoleturned 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-markedfalseis agreement.Verification (final head
e2d18233c9)pnpm --filter @objectstack/plugin-auth test: 114 files, 2454 tests passed.pnpm --filter @objectstack/plugin-auth typecheck: exit 0, includingcheck:test-typecheck. The debt is held at 10 files, 94 errors and 23 signatures.pnpm --filter @objectstack/plugin-auth build: exit 0. A fullturbo run buildover./packages/*and./packages/*/*had 71 of 71 tasks succeed at this head.pnpm --filter @objectstack/spec buildandtypecheck: exit 0.@objectstack/specsuite passed 17097 tests and failed 4, in 3 files.scoped-context.test.tspassed; its 2 failures were 60 s timeouts under load.packages/spec/src/system/auth-config.zod.tsrestored to base bytes, so they are not this diff. They arebuild-schemas-check-mode"writes the generation stamp…", which readsmismatch, and thecheck-liveness"drill ceiling" test, which fails on a JSON string truncated at 65478 bytes. The restore was trap-guarded and hash-verified.dispatch-gates.mjs --commandsderived 113 from the merge base, and--ranreconciled 113, with 112 run, 1 NOT MEASURED and 0 unrun.check:generatedis NOT MEASURED. This host's pnpm 10.31.0 refuses the wrapper's innerpnpm -s SCRIPTspelling ("unexpected argument '-s'") for all 15 member gates, so the wrapper never reached a gate body. Each member gate ran directly aspnpm run check:NAMEand exited 0.check:docswas the one stale artifact, fixed by regenerating.check:merge-driverexits 1. Its inputs (scripts/,.githooks/,.gitattributes) are byte-identical to base, andnode scripts/git-merge-regen.mjs --self-testfails 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-loadsandcheck:type-check-debtfirst refused on a missingdist. Each exited 0 after the build.pnpm exec eslint --no-inline-config --format jsonover the diff's 7.tsfiles reported 7 files, 0 errors and 0 warnings. The population is read fromeslint.config.mjs: its**/*.{ts,…}blocks, while.mdand.mdxare 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.scripts/pm/os-verify-lock.shran in UNLOCKED (declared) mode for every build and test (no usableflockon this macOS host).mainwas not merged in before opening. The five commitsorigin/maingained since base touch none of this diff's paths (git diff --statbase..origin/mainover them is empty, andgit merge-treeis clean). The PR merge ref and the queue validate the joint result.Acceptance notes
packages/services/service-settings/src/manifests/auth.manifest.ts, theaudiencegroup and theaudience_posturehelp, and in itsenand 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 lockedfalserow 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.jsonstill states "on a permitting posture, requireEmailVerification is FORCED on" as a coverage boundary. It is outside the file surface and noted only.requireEmailVerificationOnInvitation: falseis set unconditionally today. Under the defaultinvite_onlyposture, where verification is off by default, the invitation carve-out already lets whoever registers an invited address accept that invitation. Theopenopt-out widens that window to addresses registered before an invitation exists. The warning and the docs name this consequence.Generated by Claude Code