Skip to content

Commit 33b6e8b

Browse files
fix(plugin-auth): a host declares its own sign-in handoff, and no_sign_in_account_at_boot stops calling that deployment a dead end (#20861) (#20893)
Fixes #20861 Clause-②: yes (widening) ## What this changes The `no_sign_in_account_at_boot` boot report already stays quiet when a deployment has a delegated sign-in path. Since #15074 that means `ssoOnlyMode`, a configured social or OIDC provider, or enterprise SSO with a registered IdP. All three are read off `getPublicConfig()`, which is what the login page is told. A sign-in route the **host** owns, and the login page does not show, was invisible to the gate. So a hosted kernel whose owner hid the platform sign-in button logged the ERROR on every rebuild while that owner signed in through the host's handoff. Following triage's direction (5912860738), `SignInPathWiring` gains ONE declared fact, and the host sets it: - `AuthPluginOptions.hostSignInHandoff?: boolean` (default `false`). This is the only way to state it: no env var, no setting. - The `kernel:ready` hook hands the plugin's own options to `probeSignInPathWiring` as a fourth argument (`SignInPathHostView`). The gate then treats the fact like the other delegated paths: no `error`, and a `debug` line under the same grep token that names `hostSignInHandoff` as the reason. - Only a literal `true` declares it; any other value reads as not declared, which is the loud direction. A declared handoff also skips the `sys_sso_provider` read, like any path proven without the store. - ⛔ No inference. Nothing reads environment names, `platform_sso_enabled` or row counts. The host states what it wires. - ⛔ The public config does not lie. The option registers no provider, and `getPublicConfig()` returns the same value with or without it (pinned). - ⛔ Not a remedy. The ERROR text does not mention the option. The operator of a locked-out self-hosted deployment reads that line, and offering a one-word switch that silences it would turn a loud dead end into a silent one. - A deployment that does not declare the fact reports exactly as before, including every self-hosted deployment with no delegated path. The #14353 suite (`boot-sign-in-reachability.test.ts`) is unedited and green. ## For the cloud follow-up Where the hosted kernel constructs `AuthPlugin` and mounts the owner handoff (`sso_as_owner` to `sso-handoff-issue` to `sso-exchange`), declare `new AuthPlugin({ /* existing options */ hostSignInHandoff: true })`. The mutation leg of objectstack-ai/cloud#2504 (`platform_sso_enabled` false) should turn green once that is in place. ## Mechanism assumptions, measured (B1 to B6) - **B1, holds.** The gate lives in `packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts`: `resolveDelegatedSignInPath` (:414 on base), `resolveNoSignInAccountReport` (:459) and `reportIfNoSignInAccountExists` (:548). Before this PR, `probeSignInPathWiring` (:388) resolved the three facts from `SignInPathConfigView`. There is one call site, the `kernel:ready` hook in `auth-plugin.ts`: `pub` comes from `this.authManager.getPublicConfig()` (:1025), and the gate is called at :1055 and :1056. - **B2, the idiom.** A host states wiring facts as `AuthPluginOptions` constructor options: `manifestDatasource`, `databaseHooks`, and `membershipPolicy`. The organizations boot text says hosts declare `membershipPolicy` in the `AuthPlugin` constructor, "what the cloud control plane does". `os serve` (`packages/cli/src/commands/serve.ts:3991`) passes explicit fields and spreads no authored config into the plugin. So the option is reachable only by code that constructs `AuthPlugin`, which is a host. The option has one name and is read from one place. An env var would be a second spelling, and it would let an operator declare a route that nothing mounts. - **B3, holds.** `getPublicConfig()` (`auth-manager.ts:6599` to `:6799`) builds every field explicitly and never spreads `this.config`, so a new plugin option cannot leak into it. A pin compares the full return value with and without the declaration. Nothing needed to change there. - **B4.** See the one-liner above. Nothing is filed in another repository. - **B5, `yes (widening)`.** `AuthPluginOptions` is re-exported by the `.` entry (`export * from './auth-plugin.js'`). After `pnpm --filter @objectstack/plugin-auth build`, `dist/index.d.ts` carries `hostSignInHandoff?: boolean` at line 211. That line is inside `interface AuthPluginOptions` (lines 119 to 212), and `files` is `["dist","README.md","CHANGELOG.md"]`. `SignInPathWiring`, `SignInPathHostView` and `probeSignInPathWiring` occur 0 times in both `dist/index.d.ts` and `dist/rate-limit-storage.d.ts`, so the gate module stays unpublished. Positive control on the same grep: `AuthPlugin` occurs 40 and 1 times. The changeset is graded `minor`. - **B6.** `content/docs/deployment/self-hosting.mdx` documents the boot report. It now says when the same shape is logged at `debug` instead, including the new declaration, and warns that setting the option without a real handoff route hides the dead end. No docs page lists `AuthPlugin`'s host options, so there was nothing else to update. ## Evidence Pins first, then the fix, then an ablation. All pins are in `boot-sign-in-reachability.sso-gate.test.ts`. - **Red, pins only (`f6be56009`, source at base):** `Tests 8 failed | 26 passed (34)`. Six are the new #20861 pins. Two are the existing shape pins, which now expect the four-fact `NOTHING_WIRED`. Every failure is an `AssertionError`, for example `expected [ Array(1) ] to have a length of +0 but got 1` on the declared boot. The CONTROL pin and the `getPublicConfig()` pin are green, which is correct: they hold both before and after the fix. - **Green, fix (`88aff6fe7`):** the gate suite plus the #14353 suite gives `Test Files 2 passed (2)` and `Tests 85 passed (85)`, against a baseline of 77 at `9905e61ca`. - **Ablation (`88aff6fe7`):** deleted the `if (wiring.hostSignInHandoff)` branch in `resolveDelegatedSignInPath` through `scripts/ablation-replace.mjs`, in WRAP mode with an absolute-path trap. Anchor count went 1 to 0; blob went `cf6dd010f040` to `67310dc47389`. Result: `Tests 3 failed | 31 passed (34)`, the three predicted pins (the declared boot, the `debug` line, the gate-level null). The resolver pins stay green because they test the resolver, not the branch. Restore: blob after `cf6dd010f040` equals the HEAD blob, and `git diff HEAD` and `git status --porcelain` are empty. The subject is imported relatively (`./boot-sign-in-reachability`, which is `src`), so no `dist/` sits on the resolution path. - **Package (`88aff6fe7`):** `pnpm --filter @objectstack/plugin-auth test` gives `Test Files 115 passed (115)` and `Tests 2472 passed (2472)`. `typecheck` exits 0, including `check:test-typecheck` OK. - **Gates (`88aff6fe7`):** `dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 95 commands. I ran all 95, plus `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity` (`check:auth-mount-ledger` is in the 95). `--ran` answered `95 derived famil(ies) accounted for — 95 run, 0 NOT-MEASURED (a DERIVED zero …)`. Three gates first exited 3 (`PREREQUISITE NOT MET`): `check:skill-examples`, `check:type-check-debt` and `check:dual-build-cjs-loads`. After I built the `@objectstack/client-react` closure, all three exited 0 on re-run. `check:type-check-debt` reported "4 ledger entries re-measured … none above its recorded number", and `check:dual-build-cjs-loads` reported "105 published require entry points across 66 packages load". - **Lint (narrowed, `88aff6fe7`):** eslint `--no-inline-config --format json` over the three touched TS files checked 3 files with 0 errors and 0 warnings. The config itself says the two Markdown paths are "File ignored because no matching configuration was supplied", so those 3 files are the diff's whole lint population. Type-aware linting is off (`eslint.config.mjs:327`, and no `parserOptions.project`), and the diff touches no lint config or rule, so it cannot change any untouched file's lint result. The repo-wide `pnpm lint` is left to CI. ## Acceptance notes - Noted, not filed: the walled-owner neighbour (`warnIfWalledOwnerCannotVerify`) decides `hasFederatedSignIn` from `pub` only and does not read `hostSignInHandoff`. That is by #15074's design ("the neighbour is untouched by this gate", pinned). Whether a hosted kernel with the button hidden also meets that neighbour's four preconditions is NOT MEASURED here. Carrier: the cloud follow-up card, whose rebuild log will show it. - `main` moved five commits past the base (`9905e61ca` to `4edb61449`) while this ran. None of them touches this PR's five paths (measured). Per the dispatch's same-day-churn clause, no merge was made. CI's merge ref checks the combined tree. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 93d4e0e commit 33b6e8b

5 files changed

Lines changed: 241 additions & 5 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
---
4+
5+
feat(plugin-auth): a host declares its own sign-in handoff route with `hostSignInHandoff`, and the `no_sign_in_account_at_boot` boot report stops calling that deployment a dead end (#20861)
6+
7+
Clause-②: yes (widening)
8+
9+
`AuthPluginOptions` gains one option, `hostSignInHandoff?: boolean` (default
10+
`false`). The HOST that constructs the plugin sets it when it signs people in
11+
through a handoff route of its own: a route that is not a login-page provider
12+
and that creates the session without writing a `sys_account` row. A hosted
13+
kernel whose owner signs in through the control plane is the case it is for.
14+
That owner can still sign in when the login page shows no platform sign-in
15+
button:
16+
17+
```ts
18+
new AuthPlugin({ /* … */ hostSignInHandoff: true });
19+
```
20+
21+
With it declared, human `sys_user` rows and zero `sys_account` rows are that
22+
deployment's normal state. The boot report then logs the shape at `debug` and
23+
names `hostSignInHandoff` as the reason. It no longer logs an `error` saying
24+
nobody can sign in. The option's only reader is that boot report.
25+
26+
- It is a declaration, not a detection. The option is the only way to set it:
27+
there is no environment variable or setting. Nothing infers it from an
28+
environment's name, from a control plane's platform-SSO flag, or from missing
29+
rows.
30+
- The login page is not changed. `getPublicConfig()` returns the same value with
31+
or without the option, and no provider is registered.
32+
- Set it only where the host really serves such a route. On a deployment with
33+
no such route, the option turns the error for a deployment nobody can sign in
34+
to into a quiet `debug` line.
35+
- A deployment that does not declare it gets the same report as before. That
36+
includes every self-hosted deployment with no delegated sign-in path.

‎content/docs/deployment/self-hosting.mdx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,17 @@ first thing to look for — and note that it asks only whether *an* account row
540540
exists, so a row you wrote yourself silences it whether or not anyone can
541541
actually authenticate with it.
542542

543+
The same shape is logged at **debug** instead, under the same token and naming
544+
the reason, when the deployment has a sign-in path that needs no `sys_account`
545+
row of its own: SSO-only mode (`OS_AUTH_SSO_ONLY` or `ssoOnlyMode`), a
546+
configured social or OIDC provider, enterprise SSO with at least one registered
547+
identity provider, or a host that constructs `AuthPlugin` with
548+
`hostSignInHandoff: true` because it signs people in through a handoff route of
549+
its own. A deployment with none of these still reports at **error**.
550+
`hostSignInHandoff` is a statement by the code that serves such a route, not a
551+
way to quiet the report: set on a deployment without one, it hides exactly the
552+
dead end this section describes.
553+
543554
**The recovery is out of band, and it is one row.** Write a pending invitation
544555
directly against the store the deployment reads, then have that person register
545556
through the ordinary sign-up endpoint — the invitation carve-out admits that

‎packages/plugins/plugin-auth/src/auth-plugin.ts‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,31 @@ export interface AuthPluginOptions extends Partial<AuthConfig> {
225225
* `AuthManagerOptions.databaseHooks` for the hop-by-hop correction (#4802).
226226
*/
227227
databaseHooks?: BetterAuthOptions['databaseHooks'];
228+
229+
/**
230+
* [#20861] The HOST declares that it signs humans into this deployment
231+
* through a handoff route of its OWN — a sign-in path that is not a
232+
* login-page provider, and that mints the session without writing a
233+
* `sys_account` row. A hosted kernel whose owner enters through the control
234+
* plane's handoff is the case this exists for: that owner signs in whether or
235+
* not the login page shows any platform sign-in button.
236+
*
237+
* It has ONE reader, the `no_sign_in_account_at_boot` boot report
238+
* (`boot-sign-in-reachability.ts`): with it declared, "human `sys_user` rows,
239+
* zero `sys_account` rows" is this deployment's healthy state rather than an
240+
* unrecoverable dead end, and the shape is recorded at `debug`, naming this
241+
* declaration, instead of at `error`.
242+
*
243+
* ⛔ A declaration, never an inference and never a silencer. Set it only where
244+
* the host really mounts such a route; declared without one, it turns the loud
245+
* report of a deployment nobody can sign in to into a quiet one. It changes
246+
* nothing the login page is told — `getPublicConfig()` returns the same with
247+
* or without it, and no provider is registered. It is read from this option
248+
* alone (no env var, no setting), so the code that wires the handoff is the
249+
* one place it can be stated.
250+
* @default false
251+
*/
252+
hostSignInHandoff?: boolean;
228253
}
229254

230255
/**
@@ -1052,7 +1077,10 @@ export class AuthPlugin implements Plugin {
10521077
// pays for its bounded provider read only when the answer can change what
10531078
// is reported; a deployment with no delegated path is untouched and still
10541079
// reports at `error`.
1055-
const signInPath = await probeSignInPathWiring(reachability, pub, ql);
1080+
// [#20861] The plugin's own options ride along as the fourth fact: a
1081+
// handoff route the HOST owns never appears in `pub`, so the host's
1082+
// `hostSignInHandoff` declaration is the only place the gate can read it.
1083+
const signInPath = await probeSignInPathWiring(reachability, pub, ql, this.options);
10561084
const deadEnd = reportIfNoSignInAccountExists(reachability, ctx.logger, signInPath);
10571085

10581086
let ownerAccountState: WalledOwnerAccountState = 'unknown';

‎packages/plugins/plugin-auth/src/boot-sign-in-reachability.sso-gate.test.ts‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import type {
5353
SignInReachabilityFacts,
5454
} from './boot-sign-in-reachability';
5555
import { WALLED_OWNER_NO_VERIFICATION_PATH } from './walled-owner-verification-path';
56+
import type { AuthManager } from './auth-manager';
5657
import type { PluginContext } from '@objectstack/core';
5758

5859
/** The store shape this report speaks about: humans SEEN, accounts SEEN ABSENT. */
@@ -61,6 +62,7 @@ const NOTHING_WIRED: SignInPathWiring = {
6162
ssoOnlyMode: false,
6263
socialSignIn: false,
6364
enterpriseSso: false,
65+
hostSignInHandoff: false,
6466
};
6567

6668
const ENV_KEYS = [
@@ -174,9 +176,14 @@ const bootWith = async (
174176
await plugin.start(ctx);
175177
await runKernelReady(hooks);
176178
const said = (fn: { mock: { calls: unknown[][] } }) => fn.mock.calls.map((c) => String(c[0]));
179+
// The live manager `init()` registered — what the login page is told comes
180+
// from ITS `getPublicConfig()`, so that is what the #20861 pin compares.
181+
const registered = (ctx.registerService as unknown as { mock: { calls: unknown[][] } }).mock.calls;
182+
const authManager = registered.find((c) => c[0] === 'auth')?.[1] as AuthManager | undefined;
177183
return {
178184
logger,
179185
reads,
186+
publicConfig: authManager?.getPublicConfig(),
180187
errors: said(logger.error).filter((m) => m.includes(NO_SIGN_IN_ACCOUNT_AT_BOOT)),
181188
warnings: said(logger.warn).filter((m) => m.includes(NO_SIGN_IN_ACCOUNT_AT_BOOT)),
182189
debugs: said(logger.debug).filter((m) => m.includes(NO_SIGN_IN_ACCOUNT_AT_BOOT)),
@@ -454,3 +461,96 @@ describe('#15074 — the `sys_sso_provider` probe is bounded, silent and cheap',
454461
await expect(probeSignInPathWiring(DEAD_END, undefined, engine)).resolves.toEqual(NOTHING_WIRED);
455462
});
456463
});
464+
465+
// ---------------------------------------------------------------------------
466+
// [#20861] A sign-in path the HOST owns, which no login-page provider shows.
467+
//
468+
// A hosted kernel whose owner hid the platform sign-in button wires no
469+
// `oidcProviders`, yet its owner still enters through the host's own handoff
470+
// route — which writes no `sys_account` row. None of the three facts above can
471+
// see that path, because each is read off what the LOGIN PAGE is told. So the
472+
// host states it: `new AuthPlugin({ hostSignInHandoff: true })`. The pins below
473+
// hold both directions and the one thing the declaration must never touch.
474+
// ---------------------------------------------------------------------------
475+
476+
describe('#20861 — a host-declared sign-in handoff is a delegated path', () => {
477+
it('declared, humans and ZERO `sys_account` rows — NO error, NO warning', async () => {
478+
const { errors, warnings } = await bootWith(
479+
{ users: HUMANS, accounts: [] },
480+
{},
481+
{ hostSignInHandoff: true },
482+
);
483+
expect(errors).toHaveLength(0);
484+
expect(warnings).toHaveLength(0);
485+
});
486+
487+
it('CONTROL — the same boot WITHOUT the declaration still reports the error', async () => {
488+
const { errors } = await bootWith({ users: HUMANS, accounts: [] });
489+
expect(errors).toHaveLength(1);
490+
expect(errors[0]).toContain('NOBODY CAN SIGN IN');
491+
});
492+
493+
it('the suppressed shape is still recorded at `debug`, naming the declaration', async () => {
494+
const { debugs } = await bootWith(
495+
{ users: HUMANS, accounts: [] },
496+
{},
497+
{ hostSignInHandoff: true },
498+
);
499+
expect(debugs).toHaveLength(1);
500+
expect(debugs[0]).toMatch(/hostSignInHandoff/);
501+
});
502+
503+
it('declaring it changes NOTHING `getPublicConfig()` returns — the login page is not lied to', async () => {
504+
const without = await bootWith({ users: HUMANS, accounts: [] });
505+
const declared = await bootWith(
506+
{ users: HUMANS, accounts: [] },
507+
{},
508+
{ hostSignInHandoff: true },
509+
);
510+
expect(without.publicConfig).toBeDefined();
511+
expect(declared.publicConfig).toEqual(without.publicConfig);
512+
// Spelled out, because these are the fields the other three facts read:
513+
// no provider is registered and no SSO mode is claimed on the host's behalf.
514+
expect(declared.publicConfig?.socialProviders).toEqual([]);
515+
expect(declared.publicConfig?.features.sso).toBe(false);
516+
expect(declared.publicConfig?.features.ssoEnforced).toBe(false);
517+
});
518+
});
519+
520+
describe('#20861 — the fact comes from the host declaration, and from nothing else', () => {
521+
it('declared alone ⇒ no report, and the reason NAMES the declaration', () => {
522+
const wiring: SignInPathWiring = { ...NOTHING_WIRED, hostSignInHandoff: true };
523+
expect(resolveNoSignInAccountReport(DEAD_END, wiring)).toBeNull();
524+
expect(resolveDelegatedSignInPath(wiring)).toMatch(/hostSignInHandoff/);
525+
});
526+
527+
it('the resolver carries the declaration through, and defaults to NOT declared', async () => {
528+
const { engine } = engineOver({});
529+
const declared = await probeSignInPathWiring(DEAD_END, undefined, engine, {
530+
hostSignInHandoff: true,
531+
});
532+
expect(declared).toEqual({ ...NOTHING_WIRED, hostSignInHandoff: true });
533+
await expect(probeSignInPathWiring(DEAD_END, undefined, engine, {})).resolves.toEqual(
534+
NOTHING_WIRED,
535+
);
536+
});
537+
538+
it('only a literal `true` declares it — anything else keeps the report LOUD', async () => {
539+
const { engine } = engineOver({});
540+
for (const value of ['true', 1, 'yes', null]) {
541+
const wiring = await probeSignInPathWiring(DEAD_END, undefined, engine, {
542+
hostSignInHandoff: value as unknown as boolean,
543+
});
544+
expect(wiring.hostSignInHandoff).toBe(false);
545+
expect(resolveNoSignInAccountReport(DEAD_END, wiring)).toContain(NO_SIGN_IN_ACCOUNT_AT_BOOT);
546+
}
547+
});
548+
549+
it('a declared handoff spares the `sys_sso_provider` read, like any path proven without the store', async () => {
550+
const { engine, reads } = engineOver({ ssoProviders: [{ id: 'ssop_1' }] });
551+
await probeSignInPathWiring(DEAD_END, { features: { sso: true } }, engine, {
552+
hostSignInHandoff: true,
553+
});
554+
expect(reads).toEqual([]);
555+
});
556+
});

‎packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts‎

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,38 @@
195195
* When the gate suppresses the report the shape is still recorded — at `debug`,
196196
* under the same grep token, naming which configuration answered for it — so
197197
* "why is this deployment quiet" has an answer in the log and not only here.
198+
*
199+
* ## [#20861] A path the HOST owns, which the login page does not show
200+
*
201+
* The three facts above are read off `getPublicConfig()` — what the login page
202+
* is told — plus one store row behind the SSO flag. A sign-in path that is
203+
* deliberately NOT on the login page is invisible to all three. Measured on the
204+
* card: a hosted kernel whose owner hid the platform sign-in button wires no
205+
* `oidcProviders`, yet that owner still enters through the host's own handoff
206+
* route, which mints the session without writing a `sys_account` row — and
207+
* every kernel rebuild logged this report at `error` while the owner signed in
208+
* normally.
209+
*
210+
* So there is a FOURTH fact, `hostSignInHandoff`, and it is the one the gate
211+
* does not derive: the host that constructs the plugin STATES it
212+
* (`AuthPluginOptions.hostSignInHandoff`), because the host is the one party
213+
* that knows it mounted such a route. Three things it deliberately is not:
214+
*
215+
* - ⛔ **an inference** — not from an environment's name, not from a control
216+
* plane's "platform SSO enabled" flag, not from the absence of rows. Each
217+
* would silence a deployment on a guess about wiring nobody stated;
218+
* - ⛔ **a login-page provider** — declaring it registers nothing and moves
219+
* no field `getPublicConfig()` returns, so the public config keeps telling
220+
* the truth about what the login page offers. Re-registering a hidden
221+
* provider just to satisfy this gate would make it lie;
222+
* - ⛔ **a remedy** — the `error` text does not name it, and must not: the
223+
* operator of a locked-out self-hosted deployment reads that line, and a
224+
* one-word switch that makes it go quiet is the one "fix" that turns a loud
225+
* dead end into a silent one. A host with no handoff route never sets it,
226+
* and the report stays loud there.
227+
*
228+
* Only a literal `true` declares it; any other value reads as NOT declared,
229+
* which is the loud direction.
198230
*/
199231

200232
import { SystemObjectName } from '@objectstack/spec/system';
@@ -363,6 +395,13 @@ export interface SignInPathWiring {
363395
socialSignIn: boolean;
364396
/** Enterprise SSO is wired AND at least one `sys_sso_provider` row exists. */
365397
enterpriseSso: boolean;
398+
/**
399+
* [#20861] The HOST declared a sign-in handoff route of its own
400+
* (`AuthPluginOptions.hostSignInHandoff`): a path no login-page provider
401+
* shows, which mints a session without a `sys_account` row. The one member
402+
* that is STATED rather than derived — see the module doc.
403+
*/
404+
hostSignInHandoff: boolean;
366405
}
367406

368407
/**
@@ -375,32 +414,45 @@ export interface SignInPathConfigView {
375414
features?: { sso?: boolean; ssoEnforced?: boolean };
376415
}
377416

417+
/**
418+
* [#20861] The subset of `AuthPluginOptions` this gate reads — what the HOST
419+
* declared, as opposed to {@link SignInPathConfigView}, what the login page is
420+
* told. Structural for the same reason: the plugin hands its own options
421+
* straight over, and nothing here depends on the rest of them.
422+
*/
423+
export interface SignInPathHostView {
424+
hostSignInHandoff?: boolean;
425+
}
426+
378427
/**
379428
* [#15074] Resolve the wiring facts for a boot, paying for the provider probe
380429
* only when the answer can change what is reported.
381430
*
382431
* Two short-circuits, both deliberate: a boot that is not in the dead-end shape
383432
* cannot report whatever the wiring says, and a deployment that already has a
384-
* delegated path proven from config needs no store read to confirm a second
385-
* one. Every other boot pays exactly one bounded row read, and only when
386-
* enterprise SSO is switched on.
433+
* delegated path — proven from config, or [#20861] declared by the host — needs
434+
* no store read to confirm a second one. Every other boot pays exactly one
435+
* bounded row read, and only when enterprise SSO is switched on.
387436
*/
388437
export async function probeSignInPathWiring(
389438
facts: SignInReachabilityFacts,
390439
config: SignInPathConfigView | undefined,
391440
engine: BootProbeEngine | undefined,
441+
host?: SignInPathHostView,
392442
): Promise<SignInPathWiring> {
393443
const ssoOnlyMode = config?.features?.ssoEnforced === true;
394444
const socialSignIn = (config?.socialProviders?.length ?? 0) > 0;
445+
const hostSignInHandoff = host?.hostSignInHandoff === true;
395446
const needsProviderProbe =
396447
config?.features?.sso === true &&
397448
!ssoOnlyMode &&
398449
!socialSignIn &&
450+
!hostSignInHandoff &&
399451
isNoSignInAccountShape(facts);
400452
const enterpriseSso = needsProviderProbe
401453
? (await probeSsoProvidersPresence(engine)) === 'present'
402454
: false;
403-
return { ssoOnlyMode, socialSignIn, enterpriseSso };
455+
return { ssoOnlyMode, socialSignIn, enterpriseSso, hostSignInHandoff };
404456
}
405457

406458
/**
@@ -431,6 +483,13 @@ export function resolveDelegatedSignInPath(wiring?: SignInPathWiring): string |
431483
'registered, so a human signs in through it without holding a credential row here'
432484
);
433485
}
486+
if (wiring.hostSignInHandoff) {
487+
return (
488+
'the host running this deployment declared a sign-in handoff route of its own ' +
489+
'(hostSignInHandoff), so its humans enter through that route, not through a login-page ' +
490+
`provider, and hold no '${SystemObjectName.ACCOUNT}' row`
491+
);
492+
}
434493
return null;
435494
}
436495

@@ -455,6 +514,8 @@ export function resolveDelegatedSignInPath(wiring?: SignInPathWiring): string |
455514
* accounts" is its healthy resting state and not a dead end. Omitting
456515
* `wiring` answers as if nothing were configured, which keeps every
457516
* pre-#15074 caller (and the self-hosted deployment they describe) loud.
517+
* [#20861] A host-declared handoff route is one such path, the one the
518+
* host states rather than the config shows.
458519
*/
459520
export function resolveNoSignInAccountReport(
460521
facts: SignInReachabilityFacts,

0 commit comments

Comments
 (0)