Repository navigation
Commit 2c87a48
fix(plugin-auth): gate no_sign_in_account_at_boot on a delegated sign-in path (#17941)
Fixes #15074
Clause-②: no
## What was wrong
`no_sign_in_account_at_boot` decides on two store facts — human
`sys_user` rows SEEN, `sys_account` rows 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:
> managed (IdP-provisioned) users simply hold no local credential
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 by `probeSignInPathWiring` — and fires only when this
deployment also has no delegated sign-in path. Three configurations
count, each a way in that needs no operator-written `sys_account` row:
- **`ssoOnlyMode`** (`OS_AUTH_SSO_ONLY` or the config key, advertised as
`features.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;
- **a configured social / OIDC provider** — its credentials are in the
config, and a human's account row is written at their first sign-in
rather than by provisioning;
- **enterprise SSO with at least one registered IdP** — `plugins.sso` /
`OS_SSO_ENABLED` **and** a `sys_sso_provider` row.
When the gate suppresses the report, the shape is still recorded at
`debug` under the same grep token, naming which configuration answered
for it — the card's own second option.
### What is deliberately NOT silenced
- A deployment with humans, zero accounts and no delegated path is still
the unrecoverable dead end #14353 / #14495 describe, and still reports
at `error`.
- So is one that merely switched the SSO plugin **on** with no IdP
registered: that route signs nobody in, which is why the gate asks for a
provider **row** rather than for the flag. This is also why #14353's
`FEDERATED SIGN-IN IS WIRED` independence pin keeps its meaning and
stays green, unedited.
- An unreadable store answers `unknown`, which the gate reads as "no
delegated path proven" — an unreadable store keeps the report loud.
- Omitting the new argument entirely answers as if nothing were
configured, so no caller can fall quiet by forgetting it.
### Scope fence held
- `probeSignInAccountsPresence` keeps its existence-only predicate, byte
for byte. Tightening it is #15718's half, it re-decides three pinned
#14353 behaviours, and its own open question 2 is unruled. #15718 is not
addressed here; it was read as an input only.
- The 2026-09-02 admission ruling (option A) carve-out is untouched — no
admission semantics move, and boot proceeds in every shape.
- `packages/spec` is not touched. The gate is built entirely from facts
the runtime already publishes, and the one system object name it needs
is the existing `SSO_PROVIDER_OBJECT` constant in `plugin-auth`.
## Evidence
### Reproduced first, then fixed
The new suite was written and run **before** the source change, at
commit `370ab289`:
```
❯ src/boot-sign-in-reachability.sso-gate.test.ts (8 tests | 5 failed) 30ms
× SSO-only mode via `OS_AUTH_SSO_ONLY` — humans, zero accounts, and NO error 17ms
× SSO-only mode declared in CONFIG (`ssoOnlyMode`) reaches the same verdict 2ms
× the suppressed report still leaves a `debug` line NAMING the reason 2ms
× a configured SOCIAL provider is a sign-in path — no error 2ms
× enterprise SSO WITH a registered IdP is a sign-in path — no error 2ms
Tests 5 failed | 3 passed (8)
```
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:
```
✓ #14353 — the probe reads humans, not rows > ANY `sys_account` row counts — provider, issuer and ban state are not asked about 0ms
✓ #14353 — the report is wired into AuthPlugin boot > NEGATIVE CONTROL — one account exists and the boot is silent 1ms
✓ #14353 — a deployment matching BOTH shapes gets exactly one report > the neighbour is UNTOUCHED when this report did not fire 1ms
✓ #14353 — independent of ALL FOUR walled-owner preconditions > FEDERATED SIGN-IN IS WIRED — the neighbour stays quiet; this still reports 1ms
```
### Checks
| Check | Result |
|:---|:---|
| `pnpm --filter @objectstack/plugin-auth test` | `Test Files 109 passed
(109)` · `Tests 2313 passed (2313)` |
| `pnpm --filter @objectstack/plugin-auth typecheck` | exit 0 — `tsc
--noEmit`, the examples project, and `check:test-typecheck` ("OK — the
test layer compiles") |
| `pnpm lint` (repo-wide, `eslint . --no-inline-config`) | exit 0 |
| `pnpm --filter '@objectstack/plugin-auth^...' build` and `pnpm
--filter @objectstack/plugin-auth build` | exit 0 |
| Derived gate family (`node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack`, 63 commands) | 61 exit 0 |
| `pnpm check:dual-build-cjs-loads` | NOT MEASURED — exit 3,
`PREREQUISITE NOT MET`: needs a whole-repo `pnpm build` (38 packages
have no `dist`). Left to CI. |
| `pnpm check:type-check-debt` | NOT MEASURED — exit 3, `PREREQUISITE
NOT MET`: `--re-measure` needs the built closure of the ledgered
packages. Its sibling `pnpm check:type-check-coverage` ran 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.ts` contains no re-export of
`./boot-sign-in-reachability.js` (and none of its importers re-export it
either);
- the package's `exports` map has exactly two entries, `.` and
`./rate-limit-storage`. Grepping the built `dist/index.d.ts` and
`dist/rate-limit-storage.d.ts` for 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` —
what `probeSignInReachability` returns — is unchanged,
`getPublicConfig()` is read but not modified, and no REST response shape
moves. The new optional `debug?` member sits on `BootDiagnosticLogger`,
which is itself unreachable from both entries.
⇒ **`patch`**, and the changeset is graded `patch`.
## Acceptance notes
Noted, not filed: the new probe spells its object through
`SSO_PROVIDER_OBJECT` (`plugin-auth/src/sso-client-secret.ts`) because
`SystemObjectName` in `packages/spec` carries no member for
`sys_sso_provider`, unlike `USER` / `ACCOUNT` / `INVITATION` used 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 a `packages/spec`
edit, which this lane may not make.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent c8a006f commit 2c87a48
4 files changed
Lines changed: 700 additions & 5 deletions
File tree
- .changeset
- packages/plugins/plugin-auth/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
82 | 82 | | |
83 | 83 | | |
84 | 84 | | |
| 85 | + | |
85 | 86 | | |
86 | 87 | | |
| 88 | + | |
87 | 89 | | |
88 | 90 | | |
89 | 91 | | |
| |||
1002 | 1004 | | |
1003 | 1005 | | |
1004 | 1006 | | |
1005 | | - | |
| 1007 | + | |
1006 | 1008 | | |
1007 | 1009 | | |
1008 | 1010 | | |
| |||
1025 | 1027 | | |
1026 | 1028 | | |
1027 | 1029 | | |
1028 | | - | |
| 1030 | + | |
| 1031 | + | |
| 1032 | + | |
| 1033 | + | |
| 1034 | + | |
| 1035 | + | |
| 1036 | + | |
| 1037 | + | |
| 1038 | + | |
| 1039 | + | |
1029 | 1040 | | |
1030 | 1041 | | |
1031 | 1042 | | |
| |||
0 commit comments