fix(plugin-auth): gate no_sign_in_account_at_boot on a delegated sign-in path - #17941
Conversation
The boot report `no_sign_in_account_at_boot` fires at `error` on a deployment whose sign-in is delegated to an identity provider, where "human rows, zero `sys_account` rows" is the declared healthy resting state. Recorded BEFORE the fix: 5 failed | 3 passed. The five failures are the delegated-sign-in direction (the report fires where it must not); the three passes are the controls that keep the no-SSO dead end loud. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
…-in path
The boot report fires on one store shape — human `sys_user` rows, zero
`sys_account` rows — and calls it unrecoverable. On a deployment whose sign-in
is delegated to an identity provider that shape is the healthy resting state:
`AuthConfigSchema.ssoOnlyMode` states it in the contract ("managed
(IdP-provisioned) users simply hold no local credential") and names
cloud-as-IdP. Measured on a cloud tenant environment, the ERROR fired on every
kernel boot, including one that had just served a successful SSO sign-in.
The report now takes a third fact resolved from the live runtime — SSO-only
mode declared, a configured social/OIDC provider, or enterprise SSO with at
least one registered `sys_sso_provider` — and stays silent at `error` when one
of them holds, recording the shape at `debug` under the same grep token with
the reason named.
Untouched, deliberately: `probeSignInAccountsPresence` keeps its existence-only
predicate, and a deployment with no delegated path — including one that merely
switched the SSO plugin on with no IdP registered — still reports at `error`.
Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <noreply@anthropic.com>
Graded by the mechanical floor and re-derived from the built published entry: none of the module's symbols (new or pre-existing) appear in dist/index.d.ts or dist/rate-limit-storage.d.ts, the package's only two `exports` entries, and no key was added to any published payload. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 14 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 c662974294a3910edcb556755ac5626d3702bc22 && git checkout c662974294a3910edcb556755ac5626d3702bc22
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 84e6b05b6d295f1c744d236921300f447cf7791e 9546c7a1119f5bfbfc43d836229784ebbe6f6bf2 && git checkout -B drift-repro 84e6b05b6d295f1c744d236921300f447cf7791e && git merge --no-ff 9546c7a1119f5bfbfc43d836229784ebbe6f6bf2
node scripts/docs-audit/affected-docs.mjs --json 84e6b05b6d295f1c744d236921300f447cf7791e
|
Contract reviewHead reviewed: Implemented-by: ① Clause-② — re-derived in-seat from the DELIVERED diff, ⛔ not accepted from the reportThe diff adds five exported symbols to Reachability from the published entry — measured, ⛔ not assumed:
⭐ The delivery went further than this seat did and grepped the BUILT Limb (b) — no new key on an already-published payload: ⇒ Clause-②: ② The scope fence — all four held, each verified in-seat
③ Evidence discipline⭐ Reproduced BEFORE fixed, and the suite discriminated before the source moved: at #14353 pin suite on the delivered tree, file unedited: 47/47, with all three required pins green by name. Full package 109 files / 2313 tests. Typecheck, repo-wide Gates: 63 derived, 61 exit 0, 2 NOT MEASURED — Noted, ⛔ not filed
Verdict: PASSCarrier gate:
Generated by Claude Code |
Fixes #15074
Clause-②: no
What was wrong
no_sign_in_account_at_bootdecides on two store facts — humansys_userrows SEEN,sys_accountrows SEEN ABSENT — and says of that shape: "NOBODY CAN SIGN IN, and the deployment CANNOT BE RECOVERED FROM INSIDE".Those two facts were written for a deployment whose only way in is a credential row. On a deployment whose sign-in is delegated to an identity provider, the same shape is the healthy resting state, and the auth-config contract says so in as many words.
AuthConfigSchema.ssoOnlyMode(packages/spec/src/system/auth-config.zod.ts), naming the cloud-as-IdP case explicitly:The card measured it on a cloud tenant environment: the ERROR on every kernel boot, including a boot that had just served a successful SSO sign-in, and the only ERROR line in the whole smoke log.
The gate
The report now takes a third fact —
SignInPathWiring, resolved from the live runtime byprobeSignInPathWiring— and fires only when this deployment also has no delegated sign-in path. Three configurations count, each a way in that needs no operator-writtensys_accountrow:ssoOnlyMode(OS_AUTH_SSO_ONLYor the config key, advertised asfeatures.ssoEnforced) — the deployment declaring IdP-only sign-in. Generic over the IdP, which is why a platform-SSO tenant kernel is sure to carry it;plugins.sso/OS_SSO_ENABLEDand asys_sso_providerrow.When the gate suppresses the report, the shape is still recorded at
debugunder the same grep token, naming which configuration answered for it — the card's own second option.What is deliberately NOT silenced
sys_accountrows boots silently into an unrecoverable state — say so loudly atkernel:ready#14353 / docs(self-hosting): a directory-seeded, credential-less deployment has no documented recovery path — the published instruction points at the doorinvite_onlyshuts #14495 describe, and still reports aterror.sys_accountrows boots silently into an unrecoverable state — say so loudly atkernel:ready#14353'sFEDERATED SIGN-IN IS WIREDindependence pin keeps its meaning and stays green, unedited.unknown, which the gate reads as "no delegated path proven" — an unreadable store keeps the report loud.Scope fence held
probeSignInAccountsPresencekeeps its existence-only predicate, byte for byte. Tightening it is plugin-auth:probeSignInAccountsPresenceis existence-only, so one unusablesys_accountrow silencesno_sign_in_account_at_bootpermanently #15718's half, it re-decides three pinned A deployment with human rows and zerosys_accountrows boots silently into an unrecoverable state — say so loudly atkernel:ready#14353 behaviours, and its own open question 2 is unruled. plugin-auth:probeSignInAccountsPresenceis existence-only, so one unusablesys_accountrow silencesno_sign_in_account_at_bootpermanently #15718 is not addressed here; it was read as an input only.packages/specis not touched. The gate is built entirely from facts the runtime already publishes, and the one system object name it needs is the existingSSO_PROVIDER_OBJECTconstant inplugin-auth.Evidence
Reproduced first, then fixed
The new suite was written and run before the source change, at commit
370ab289:The five failures are the false-fire direction (
expected [ Array(1) ] to have a length of +0 but got 1— the ERROR fired on a platform-SSO-shaped population). The three passes are the controls that keep the no-SSO dead end loud, so the suite was already discriminating before the fix. After the fix, the same file: 26 passed (26), both directions.#14353's pin suite on the delivered tree
vitest run --reporter=verbose src/boot-sign-in-reachability.test.ts— 47 passed (47), file unedited. The three pins named in the dispatch, plus the independence pin this change had to preserve:Checks
pnpm --filter @objectstack/plugin-auth testTest Files 109 passed (109)·Tests 2313 passed (2313)pnpm --filter @objectstack/plugin-auth typechecktsc --noEmit, the examples project, andcheck:test-typecheck("OK — the test layer compiles")pnpm lint(repo-wide,eslint . --no-inline-config)pnpm --filter '@objectstack/plugin-auth^...' buildandpnpm --filter @objectstack/plugin-auth buildnode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, 63 commands)pnpm check:dual-build-cjs-loadsPREREQUISITE NOT MET: needs a whole-repopnpm build(38 packages have nodist). Left to CI.pnpm check:type-check-debtPREREQUISITE NOT MET:--re-measureneeds the built closure of the ledgered packages. Its siblingpnpm check:type-check-coverageran green.Both NOT MEASURED rows are the gates' own exit-3 "nothing was measured" code, read from the gate's own verdict line, not from a shell
$?behind a pipe.Clause-② — re-derived from the delivered diff
The mechanical floor: minor iff the diff adds a new exported symbol reachable from the package's published entry, or a new key on an already-published payload.
New exported symbols in the diff, all in
packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts:SignInPathWiring,SignInPathConfigView,probeSsoProvidersPresence,probeSignInPathWiring,resolveDelegatedSignInPath.Reachability, measured against the built entries rather than read off the source:
packages/plugins/plugin-auth/src/index.tscontains no re-export of./boot-sign-in-reachability.js(and none of its importers re-export it either);exportsmap has exactly two entries,.and./rate-limit-storage. Grepping the builtdist/index.d.tsanddist/rate-limit-storage.d.tsfor each of the five new names returns 0 occurrences — as it does for every pre-existing name in that module (NO_SIGN_IN_ACCOUNT_AT_BOOT,probeSignInReachability,reportIfNoSignInAccountExists, …). Positive control on the same grep in the same file:AuthPlugin, 30 occurrences.No key was added to any published payload:
SignInReachabilityFacts— whatprobeSignInReachabilityreturns — is unchanged,getPublicConfig()is read but not modified, and no REST response shape moves. The new optionaldebug?member sits onBootDiagnosticLogger, which is itself unreachable from both entries.⇒
patch, and the changeset is gradedpatch.Acceptance notes
Noted, not filed: the new probe spells its object through
SSO_PROVIDER_OBJECT(plugin-auth/src/sso-client-secret.ts) becauseSystemObjectNameinpackages/speccarries no member forsys_sso_provider, unlikeUSER/ACCOUNT/INVITATIONused a few lines above it. The two spellings sit side by side in this file. It is a naming-consistency observation, not a defect — nothing is unenforced and nothing is wrong at runtime — and closing it would be apackages/specedit, which this lane may not make.Generated by Claude Code