fix(plugin-auth): gate the dev-admin seed on a LOGIN, not on user rows, and admit its own provisioning call - #14352
Conversation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
📓 Docs Drift CheckThis PR changes 2 package(s): 28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 29 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 d99eee1c39c61aa4ae5a5f320934bcb5530c4875 && git checkout d99eee1c39c61aa4ae5a5f320934bcb5530c4875
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d62f990a985eb15152579ad34c99f43e76e3657a 31fd3968cf0c0aadc5f75575bbbb411541b8bf53 && git checkout -B drift-repro d62f990a985eb15152579ad34c99f43e76e3657a && git merge --no-ff 31fd3968cf0c0aadc5f75575bbbb411541b8bf53
node scripts/docs-audit/affected-docs.mjs --json d62f990a985eb15152579ad34c99f43e76e3657a
|
…inor Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
|
CI status note (PM seat, session_01AUF1NoViznQK32gqpK8wS8): Generated by Claude Code |
…lugin.ts (#14157) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
|
Landing provenance (PM seat,
Generated by Claude Code |
Fixes #14157
objectstack devdeclares, in its own--help, a known loginable dev admin. The gate that implemented it asked a different question — does any humansys_userrow exist? — and those two are the same question only while every user row carries a credential. An app that declares people indefineStack({ data })breaks that: the declarative seed is awaited insideAppPlugin.start(), so it always lands before the seed's ownkernel:readyhook, the database is non-zero-user before the check runs, and the admin is never minted — on that boot or on any later one, because the rows survive. The deployment ends up with no loginable account at all.A seeded person is a directory row with no account. It is not a login, and treating it as one is the defect.
The fix is TWO halves, and the second one is what makes the first work
Half 1 — the gate (
packages/plugins/plugin-auth/src/dev-admin-seed-gate.ts, new; owns the predicate and the argument). The seed acts while no account holds the configured seed address AND no local password login (sys_account.provider_id = 'credential') exists anywhere. Those are the card's own two suggested spellings, both used, because each guards a case the other does not: the first is the never-overwrite half (and it now covers a federated account on that address too), the second is "the environment already has the thing the seed provides". A credential store that cannot be read is its own verdict —unanswerable— and the seed declines and says so rather than minting a known-credential admin into an environment it could not see.Half 2 — admission for the seed's own provisioning call (
AuthManager.stageOperatorProvisioning). This is not a nicety. The seed provisions through better-auth's realsignUpEmail, and that call has to be admitted; its admission used to ride on the audience gate's bootstrap bypass, "zero HUMAN users" — which the same 13 seeded rows also answer "populated". Measured: with 13 seeded people and zero accounts, under the defaultinvite_onlyposture, the seed's own lane comes backSELF_REGISTRATION_CLOSED. A fix that moved only the gate would therefore have produced a seed that decides to run and a gate that then refuses it — the exact driftisHumanUserRow's own doc warns about. Case ⓪ of the new pin measures that refusal directly, which is what keeps every case after it non-vacuous.So the seed now declares what it is — the deployment's own boot command provisioning its admin, the operator creation class — instead of inferring admission from a population it does not own. This moves no public door: nothing outside the process can stage the declaration, the only caller is hard-gated to
NODE_ENV==='development', it names one address, it is cleared in afinally, and a caller killed between the two is cleaned up by a 60-second TTL. Case ④ pins that the stranger's/sign-up/emailis refusedSELF_REGISTRATION_CLOSEDon exactly this population, before and after the seed runs.The audience gate's own predicate is deliberately unchanged. It is a public self-registration carve-out, so the population it counts has to stay humans.
The second surface:
bootstrap-status— measured, and folded inTriage asked for this to be measured and folded in or declared out of scope with the reading. Measured, it is a real disagreement of its own rather than a look-alike: the handler counted
sys_userrows with no filter at all, which made it the one call site out of step with the threeisHumanUserRowconsumers. On a database still carrying the legacyusr_systemservice row it answeredhasOwner: true— the console withholding the first-run setup flow — while the admission gate and plugin-security's first-user detection both stood ready to admit and promote the first human.It now asks
AuthManager.hasBootstrapWindow(), the same question the admission gate answers, so the console can never offer a first-run creation the platform would refuse, nor withhold one it would allow. Cases ⑦–⑧ drive the real route. A no-engine composition (MSW/mock mode) still reads as bootstrapped, so the SPA falls through to its normal login flow exactly as before.What is deliberately NOT in this PR, with the reading: whether that window should mean "no LOGIN" rather than "no human users". Flipping it is coupled to the admission gate's predicate — moving only
bootstrap-statusmakes the console offer a setup flow the gate then refuses 403, and moving both admits a stranger's self-registration on a directory-seeded deployment. That is a posture ruling, not an implementation choice, so it is filed with its measurements and three costed options as #14349 rather than decided here.Declared riders, both named on purpose
packages/cli/src/commands/dev.ts— the claim fencedpackages/cliwith one permitted exception: the sentence that states the gate predicate. Two lines carry that one statement and both are corrected: the--seed-adminflagdescription(the declared contract, user-visible) and the in-code comment restating it aboveconst seedAdmin. Nothing else in that package is touched — notably not the "then promote it to platform admin" clause, which is a separate claim and a separate finding (below).scripts/check-route-envelope.mjs— the ruled exemption forauth-plugin.tsdrops3 → 2. The third unenveloped body was the handler's no-engine early return, which existed only because the handler resolved the data engine itself; asking the probe (which reports "no window" without an engine) folds that branch into the ordinary answer. Shrinking a ruled boundary needs nobody's leave; widening is the maintainer-only direction. The gate's own--self-testruns ahead of it and passes.Interplay with #14000 (not a dependency)
#14000's ruled remedy asserts that the seeded admin can sign in on a fresh install. That assertion is sound for the current scaffold, which seeds no users, and this change is what makes it robust if the scaffold ever gains a person seed. Neither blocks the other and no
Blocked-by:is implied in either direction.Verification
All of the following ran on
c89600f4, with a clean working tree, through the shared verify lock.pnpm --filter @objectstack/plugin-auth exec vitest runpnpm --filter @objectstack/plugin-auth typecheckpnpm exec eslint --no-inline-config .node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, re-derived after the gate-script edit: 49 commands)check:route-envelope,check:engine-double-contract,check:where-matcher,check:test-source-alias,check:type-check-coverage,check:dispatcher-error-vocabulary,check:auth-mount-ledger,check:nul-bytesNot measured locally, each for a stated reason, and each of them something CI runs anyway:
check:test-completeness(exit 3, PREREQUISITE NOT MET by design — it grades a saved turbo test log and none was passed);check:i18nandcheck:i18n-coverage(PREREQUISITE NOT MET — they run the built CLI and its whole build closure);check:type-check-debt --re-measureandcheck:dual-build-cjs-loads(need the full workspace build);check:pm-dispatch-gatesandcheck-half-states(both exceeded the container's foreground cap — timed out at 150s and 380s, so NOT MEASURED rather than red).For the debt ledger specifically, the risk it covers was measured directly instead:
plugin-auth'stsconfig.jsonexcludes**/*.test.ts, so the packagetypechecksays nothing about a new test file. Compiling the new test file plus the changed sources through a temporary config with that exclusion lifted exits 0, and--listFilesconfirms both new files were actually in the program — so the ledgered count cannot drift up because of this diff.Ablation — predicted RED, both legs, and both went red
Predicted before the run. No rebuild leg is owed: the pin imports its subjects relatively (
./auth-plugin.js,./auth-manager.js), so vitest resolves them to this package'ssrc/, never through the packageexportstodist/— and a leg that had stayed green would itself have been the dist-resolution signature. Each leg proved the mutation reached disk (anchor-count before/after plus agit hash-objectchange, refusing to run the measurement otherwise) and proved the restore (git checkout HEAD --, then blob hash equal to the HEAD blob andgit diff HEADempty). The whole script carriedtrap restore EXIT INT TERM.humans.length > 0, ticket left in place: 6 of 13 failed (①, ①b, ②, ②b, ④, ⑤b)false, gate left in place: 4 of 13 failed (①, ①b, ②, ④)A2 is the one worth reading: it is the measurement that this PR's second half is load-bearing, not decoration.
Out-of-scope findings, filed unassigned (searched first; a positive control confirmed the search channel was answering)
plugin-securitypromotes the OLDEST humansys_userrow, which on a people-seeding app is a credential-less directory row:admin_full_accesslands on a row nobody can sign in as, and the admin this PR mints is loginable but unpromoted. Same row-versus-login shape, one package over. A code reading, not an end-to-end measurement, and stated as such. It is also why the--helpclause "then promote it to platform admin" was left alone here.docs/qa/platform-checklist/areas/cli.jsonquotes the old--seed-adminsentence verbatim in two places and goes stale when this lands. Labelledfinding; a different surface with its own authoring playbook.Adjacent but not a duplicate of #14348: #11979 is about config-anchoring the
singleposture, not about which row it selects.Contract review (Clause ②)
Re-declared
yeson the card after review: this diff widens a public surface, so the@objectstack/plugin-authchangeset is minor, not patch. The exported-symbol delta, split by whether it actually reaches a consumer:On the package barrel —
src/index.tscarriesexport * from './auth-manager.js', so every public member of the exportedAuthManagerclass is public API. Four are new:stageOperatorProvisioning(email: string): voidclearOperatorProvisioning(email: string): voidisOperatorProvisioning(email: unknown): booleanhasBootstrapWindow()— async, answers a booleanA fifth member added in the same hunk,
prunePendingOperatorProvisioning(), isprivateand is not part of the surface.Also on this side, and the reason "minor" is not only about added symbols:
GET /api/v1/auth/bootstrap-statuschanges its published answer. On a database whose only row is the legacyusr_systemservice account it answeredhasOwner: trueand now answersfalse. Same field, same type, same route, different verdict on that population — a consumer-visible behaviour change, which is exactly what the level is for.Module-only, not on the barrel —
src/dev-admin-seed-gate.tsis imported relatively byauth-plugin.tsand is not re-exported fromsrc/index.ts, so these four are internal to the package and carry no compatibility promise:export interface DevAdminSeedProbeEngineexport type DevAdminSeedGateVerdictexport const CREDENTIAL_PROVIDER_IDexport async function decideDevAdminSeedGateChangeset levels:
@objectstack/plugin-authminor ·@objectstack/clipatch (flag description text and a comment only — no exported symbol and no behaviour of its own). Nothing here is breaking: every addition is additive, no export is removed or narrowed, and no ADR-0087 disposition is owed.Adjacent mechanical fix
The census anchor in
content/docs/permissions/system-context.mdxmovedauth-plugin.ts:1296->auth-plugin.ts:1301, because this PR inserted lines above that read site. Gate family:check-system-context-census(lint.yml). Pure line rot, not a population change — written by the gate's own--fix, which re-anchored one row and refused nothing;git diff --statfor that commit is one file, one line. Verdict after:check-system-context-census: OK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read, and its--self-testpasses.That file is outside this claim's original file surface, so it is declared here and the claim comment on #14157 is amended to include it.
Worth recording for whoever derives gates next: this family is not derivable from the change that breaks it. It declares 29 path literals — its own anchor page plus specific
packages/specfiles — andpackages/plugins/plugin-auth/src/auth-plugin.tsis not among them, so a code-only change set scores itsilent(the derivation's own weakest verdict, explicitly "not a clearance"). It appears in the derived list only once the anchor page itself is in the change set — i.e. after the repair, never before it. Its real trigger is any source line movement under one of 145 anchors across 45 files, which no path literal can enumerate.🤖 Generated with Claude Code
https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Generated by Claude Code
Generated by Claude Code
Generated by Claude Code