From daaa24401395d619f8375ef7f78bf8d09c5e7758 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 05:51:04 +0000 Subject: [PATCH 1/5] feat(plugin-auth): report the zero-account boot dead end at kernel:ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deployment with human `sys_user` rows and zero `sys_account` rows cannot be recovered from inside: nobody can sign in, the bootstrap carve-out counts humans and so does not open, `invite_only` refuses self-registration, and no administrator exists to invite anyone. Today it boots silently. Reports it at `kernel:ready`, at `error` level, naming both the consequence and the remedy. Extends the existing walled-owner reporter family rather than opening a parallel one: same hook, and the bounded human-population page is read ONCE and shared with `probeWalledOwnerAccountState`, which now accepts the already-known answer. At most one report per boot — the error subsumes the walled-owner warning when a deployment matches both shapes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --- .../plugins/plugin-auth/src/auth-plugin.ts | 39 ++- .../src/boot-sign-in-reachability.ts | 272 ++++++++++++++++++ .../src/walled-owner-verification-path.ts | 30 +- 3 files changed, 326 insertions(+), 15 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 6e96f028e1..62bf5624ac 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -78,6 +78,10 @@ import { warnIfWalledOwnerCannotVerify, type WalledOwnerAccountState, } from './walled-owner-verification-path.js'; +import { + probeSignInReachability, + reportIfNoSignInAccountExists, +} from './boot-sign-in-reachability.js'; import { judgePlatformAdmin, isPlatformAdminUser, type PlatformAdminActor } from './platform-admin-gate.js'; import { runAdminBanUser, @@ -1036,6 +1040,19 @@ export class AuthPlugin implements Plugin { // hook below (registration order), so the probe reads the pre-seed // store — the predicate's dev-seed clauses are written for exactly // that reading. + let ql: IDataEngine | undefined; + try { ql = ctx.getService('objectql'); } catch { ql = undefined; } + + // [#14353] "Can ANYONE sign in?" — asked UNCONDITIONALLY, because it is + // independent of all four preconditions below: a deployment that is + // unwalled, or declares no owner, or wires an email transport is just as + // unrecoverable when it has human rows and no `sys_account` row. This is + // the family's ONE store read: the human-population page is paged here + // and the answer handed to the walled-owner probe, so no boot pages + // `sys_user` twice. Cost on a fresh store is a single bounded page. + const reachability = await probeSignInReachability(ql); + const deadEnd = reportIfNoSignInAccountExists(reachability, ctx.logger); + let ownerAccountState: WalledOwnerAccountState = 'unknown'; if ( !hasEmailTransport && @@ -1043,14 +1060,22 @@ export class AuthPlugin implements Plugin { postureEnforcesWall(resolveTenancyPosture()) && resolvePlatformOwnerEmail() ) { - let ql: IDataEngine | undefined; - try { ql = ctx.getService('objectql'); } catch { ql = undefined; } - ownerAccountState = await probeWalledOwnerAccountState(ql); + ownerAccountState = await probeWalledOwnerAccountState(ql, { + humanUsers: reachability.humanUsers, + }); + } + // [#14353] ONE report per boot. A deployment can match both shapes at + // once (no accounts AND a declared owner that cannot verify); the + // no-sign-in error strictly subsumes the walled-owner warning there — + // an owner who cannot reach platform-admin standing is moot when nobody + // can sign in at all — so the warning is suppressed rather than stacked + // on top of it. When the error did not fire, the warning is untouched. + if (!deadEnd) { + warnIfWalledOwnerCannotVerify( + { hasEmailTransport, hasFederatedSignIn, ownerAccountState }, + ctx.logger, + ); } - warnIfWalledOwnerCannotVerify( - { hasEmailTransport, hasFederatedSignIn, ownerAccountState }, - ctx.logger, - ); }); // Dev-only: provision a known, loginable platform admin on an empty DB. diff --git a/packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts b/packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts new file mode 100644 index 0000000000..3ed72a2923 --- /dev/null +++ b/packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts @@ -0,0 +1,272 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#14353] The deployment that seeded a people directory and no logins. + * + * A store with human `sys_user` rows and ZERO `sys_account` rows is an + * unrecoverable deployment, and today it is entirely silent. Measured on a + * real `ObjectQL` over `better-sqlite3` (#14349, 13 seeded human rows, no + * accounts, default audience posture): + * + * - nobody can sign in — there is no `sys_account` row for anyone; + * - `api.signUpEmail(...)` answers `SELF_REGISTRATION_CLOSED`: the + * first-account bootstrap carve-out counts HUMANS, and humans exist, so + * it does not open; + * - the default `invite_only` audience posture refuses self-registration; + * - no administrator exists who could send an invitation. + * + * ⇒ Outside development (the dev-admin seed is hard-gated to + * `NODE_ENV==='development'`) the deployment cannot be recovered from inside, + * and its only symptom is a 401 on credentials nobody holds. + * + * ⛔ **Boot PROCEEDS, and no admission semantics change here.** This module + * only reports. Whether the carve-out should count humans or logins was + * #14349's question and is RULED: maintainer ruling 2026-09-02 (decision + * batch #2, verbatim 「14361 你不处理;14261 现在就改,定位为 minor;其他同意」, + * adopting option **A**) — `isBootstrapCreation()` keeps answering on the + * human population, no public door moves, and the ruling names this card's + * licence explicitly: its "message text may now say plainly that the door + * stays shut and the remedy is out-of-band provisioning". The message below + * says exactly that. + * + * ## Why this is `error` and the walled-owner neighbour is `warn` + * + * AGENTS.md → "Degradation log levels" decides with one question: after the + * degradation, does the system still look normal from the outside while + * something it claims is true has not landed? Here, emphatically yes — the + * runtime boots clean, serves, and answers 401; the loss surfaces to whoever + * is handed the deployment, who cannot connect it to this boot. The + * walled-owner neighbour reports a DEGRADED path (one address cannot reach + * platform-admin standing); this reports an UNRECOVERABLE one (no address can + * reach anything). Per that rule an `error` owes two things in its first + * line — ① the consequence, concretely, including that the system will keep + * looking healthy, and ② the fix — and the message carries both. + * + * The #13398-class ruling caps this, and is satisfied rather than dodged: + * what it forbids is GROWING `error?` onto a published sink that lacks it. + * {@link BootDiagnosticLogger} declares `error?` AND `warn?` from birth and + * nothing is widened — in particular the neighbouring + * `WalledOwnerVerificationLogger` (a `warn?`-only sink) is untouched. Spelled + * the `share-link-service.ts` way: a conditional `error?.(…)` against a host + * sink without `error` emits nothing, so the `warn` fallback is an explicit + * branch, and a host that publishes only `warn` still hears this. + * + * ## Why `kernel:ready`, and why it shares the neighbour's hook + * + * Same hook site as the [#11640] walled-owner verification-path reporter, and + * for a stronger reason than symmetry: both questions are answered from ONE + * bounded human-population page read, performed here + * ({@link probeHumanUsersPresence}) and handed to + * `probeWalledOwnerAccountState` rather than read a second time. A second + * independent prober would double the boot read and could emit two + * overlapping reports for one deployment; the hook therefore emits at most + * ONE report, this one taking precedence — an owner who cannot verify is moot + * on a deployment where nobody can sign in at all. + * + * ## Why silence when the store cannot be read + * + * The neighbour warns on an unanswerable probe ("noisy over silent about a + * real dead end"). That posture does not survive the level change: at `error` + * it would fire on every engine-less boot — every MSW/mock embedding, every + * host that serves auth without an `objectql` service — which is the + * fires-on-every-boot failure mode this family has controls against, and + * AGENTS.md's own caution that escalating trains everyone to skim `error`. + * This report therefore makes a POSITIVE claim only: it fires when humans + * were SEEN and accounts were SEEN ABSENT. Every other shape, `unknown` + * included, is silent — an absence of measurement is not evidence of a dead + * end. + */ + +import { SystemObjectName } from '@objectstack/spec/system'; +import { isHumanUserRow } from './audience-posture.js'; +import { SELF_REGISTRATION_CLOSED } from './audience-posture.js'; + +/** + * The stable NAME of this report — the grep token an operator or a support + * thread keys on, the same way the neighbouring + * `walled_owner_no_verification_path` leads its line. + */ +export const NO_SIGN_IN_ACCOUNT_AT_BOOT = 'no_sign_in_account_at_boot'; + +/** + * The bounded read this family's probes perform — every data engine satisfies + * it. One shape for the whole family: `WalledOwnerProbeEngine` is an alias of + * this, so the two probes cannot drift apart on what they require of a store. + */ +export interface BootProbeEngine { + find(object: string, query: Record, options?: unknown): Promise; +} + +/** + * A bounded existence answer. `'unknown'` is NOT a third kind of absence — it + * means the question was not answered, and every consumer here treats it as + * "make no claim". + */ +export type BootStorePresence = 'present' | 'absent' | 'unknown'; + +/** + * The page size of the human-population probe. Matches the neighbour's + * historical `PROBE_LIMIT`: a FULL page of non-humans cannot prove absence and + * so reads as populated (the direction that fails safe — this report only ever + * fires on a POSITIVE human sighting). + */ +export const HUMAN_POPULATION_PROBE_LIMIT = 50; + +/** What the store says at boot about whether anyone can sign in at all. */ +export interface SignInReachabilityFacts { + /** Whether any human `sys_user` row exists ({@link isHumanUserRow}). */ + humanUsers: BootStorePresence; + /** Whether any `sys_account` row exists — a credential, a federated link, any login. */ + signInAccounts: BootStorePresence; +} + +const SYSTEM = { context: { isSystem: true } }; + +const asRows = (raw: unknown): Record[] => { + if (Array.isArray(raw)) return raw as Record[]; + const records = (raw as { records?: unknown } | null | undefined)?.records; + return Array.isArray(records) ? (records as Record[]) : []; +}; + +const usable = (engine: BootProbeEngine | undefined): engine is BootProbeEngine => + !!engine && typeof engine.find === 'function'; + +/** + * THE human-population page read for this family — performed once per boot. + * + * Humans, not rows: a database still carrying the legacy `usr_system` service + * row is EMPTY of humans, and counting it would make a fresh install look + * populated. {@link isHumanUserRow} owns that predicate for all three call + * sites; this one does not re-spell it. + * + * Never throws: an unanswerable read is `'unknown'`. + */ +export async function probeHumanUsersPresence( + engine: BootProbeEngine | undefined, +): Promise { + if (!usable(engine)) return 'unknown'; + try { + const page = asRows( + await engine.find(SystemObjectName.USER, { limit: HUMAN_POPULATION_PROBE_LIMIT }, SYSTEM), + ); + // A full page of non-humans cannot prove absence: it reads as populated. + const humansExist = page.some(isHumanUserRow) || page.length >= HUMAN_POPULATION_PROBE_LIMIT; + return humansExist ? 'present' : 'absent'; + } catch { + return 'unknown'; + } +} + +/** + * Whether ANY `sys_account` row exists — one bounded row is the whole + * question. No predicate over the rows on purpose: a row of any provider, + * any issuer, banned or not, means SOMEBODY has a login, and this report is + * about the total absence of one. Never throws. + */ +export async function probeSignInAccountsPresence( + engine: BootProbeEngine | undefined, +): Promise { + if (!usable(engine)) return 'unknown'; + try { + const rows = asRows(await engine.find(SystemObjectName.ACCOUNT, { limit: 1 }, SYSTEM)); + return rows.length > 0 ? 'present' : 'absent'; + } catch { + return 'unknown'; + } +} + +/** + * Both facts, from one probe pass. The account read is skipped when no human + * was seen: with no humans the report cannot fire whatever the accounts say + * (a genuinely empty store is a healthy pre-bootstrap deployment, not a dead + * end), so a fresh boot pays for one page read and nothing else. + */ +export async function probeSignInReachability( + engine: BootProbeEngine | undefined, +): Promise { + const humanUsers = await probeHumanUsersPresence(engine); + if (humanUsers !== 'present') return { humanUsers, signInAccounts: 'unknown' }; + return { humanUsers, signInAccounts: await probeSignInAccountsPresence(engine) }; +} + +/** + * The predicate and its message, with no I/O — the whole decision, testable + * fact by fact. + * + * Returns the report text for the ONE dead-end shape (humans SEEN, accounts + * SEEN ABSENT), or `null` for every other shape. Each `null` is `null` for its + * own reason: + * + * - **no humans** — a genuinely empty store is a healthy deployment whose + * first-account bootstrap is still ahead of it; the carve-out opens for + * the first visitor exactly as designed; + * - **an account exists** — somebody can sign in, and whoever that is can + * invite the rest; nothing here is unrecoverable; + * - **`unknown` on either fact** — the store was not consulted (no engine, + * a probe failure). See the module doc: at `error` level this report makes + * a positive claim or none at all. + */ +export function resolveNoSignInAccountReport(facts: SignInReachabilityFacts): string | null { + if (facts.humanUsers !== 'present') return null; + if (facts.signInAccounts !== 'absent') return null; + + return ( + `[auth] ${NO_SIGN_IN_ACCOUNT_AT_BOOT}: this deployment has human '${SystemObjectName.USER}' rows ` + + `but ZERO '${SystemObjectName.ACCOUNT}' rows — there is no credential, no federated link, no login ` + + 'of any kind, for anyone. NOBODY CAN SIGN IN, and the deployment CANNOT BE RECOVERED FROM INSIDE: ' + + 'the first-account bootstrap carve-out counts HUMANS and humans already exist, so it does not open ' + + `(maintainer ruling 2026-09-02, option A — the door stays shut); self-registration is refused with ` + + `${SELF_REGISTRATION_CLOSED} under the default 'invite_only' audience posture; and no administrator ` + + 'exists who could send an invitation. Boot continues and this deployment will keep LOOKING healthy — ' + + 'its only symptom is a 401 on credentials nobody holds. Fix it from OUTSIDE the running product, ' + + `either: (1) PROVISION AN ACCOUNT OUT OF BAND — write a '${SystemObjectName.ACCOUNT}' credential row ` + + `for one of the existing '${SystemObjectName.USER}' rows directly against the store, or re-run the ` + + 'provisioning job that seeded those people so it seeds their logins too; or (2) OPEN THE AUDIENCE ' + + "POSTURE — set `audience.posture` to 'open', or to 'email_domain' with your directory's domain " + + 'allowlisted, so an existing person can register their own login, then close it again. Neither ' + + 'happens by itself.' + ); +} + +/** + * The `error` channel this report needs, with the `warn` fallback the + * #13398-class ruling requires of a sink that may not declare `error`. Both + * members are optional and both are declared HERE, at birth: no published sink + * is widened by this module. + */ +export interface BootDiagnosticLogger { + error?(message: string, ...rest: unknown[]): void; + warn?(message: string, ...rest: unknown[]): void; +} + +/** + * Emit the report when this deployment is in the dead-end shape. Returns the + * message that was logged, or `null` when nothing was wrong — the return value + * is what tests assert on and what the caller reads to decide precedence, so a + * shape that must stay quiet is pinned by `null` rather than by the absence of + * a log call. + * + * Never throws: a diagnostic that can break a boot is worse than the gap it + * reports. + */ +export function reportIfNoSignInAccountExists( + facts: SignInReachabilityFacts, + logger?: BootDiagnosticLogger, +): string | null { + let message: string | null = null; + try { + message = resolveNoSignInAccountReport(facts); + } catch { + return null; + } + if (!message) return null; + try { + // An `error?.(…)` against a sink without `error` emits NOTHING, so the + // `warn` fallback is an explicit branch rather than an optional call. + if (logger?.error) logger.error(message); + else logger?.warn?.(message); + } catch { + /* a logger that throws must not abort the boot */ + } + return message; +} diff --git a/packages/plugins/plugin-auth/src/walled-owner-verification-path.ts b/packages/plugins/plugin-auth/src/walled-owner-verification-path.ts index b6b9651a22..e6434fea3f 100644 --- a/packages/plugins/plugin-auth/src/walled-owner-verification-path.ts +++ b/packages/plugins/plugin-auth/src/walled-owner-verification-path.ts @@ -103,6 +103,11 @@ import { isConfiguredPlatformAdminEmail, resolvePlatformAdminEmails } from '@obj import { postureEnforcesWall } from '@objectstack/spec/security'; import { SystemObjectName } from '@objectstack/spec/system'; import { isHumanUserRow } from './audience-posture.js'; +import { + type BootProbeEngine, + type BootStorePresence, + probeHumanUsersPresence, +} from './boot-sign-in-reachability.js'; /** * The stable NAME of this warning — the "named" half of the ruled "loud, named @@ -163,10 +168,13 @@ export type WalledOwnerAccountState = /** The store could not be consulted (no engine, probe failure) — treated as the dead end, loudly. */ | 'unknown'; -/** The bounded read this module's probe performs — every data engine satisfies it. */ -export interface WalledOwnerProbeEngine { - find(object: string, query: Record, options?: unknown): Promise; -} +/** + * The bounded read this module's probe performs — every data engine satisfies + * it. [#14353] Now an alias of the family's one probe-engine shape + * ({@link BootProbeEngine}), so the walled-owner probe and the + * sign-in-reachability probe cannot drift apart on what they ask of a store. + */ +export type WalledOwnerProbeEngine = BootProbeEngine; /** * What this deployment has wired that could ever verify an address. All @@ -225,11 +233,11 @@ export interface VerificationPathWiring { */ export async function probeWalledOwnerAccountState( engine: WalledOwnerProbeEngine | undefined, + known?: { humanUsers?: BootStorePresence }, ): Promise { const config = resolvePlatformAdminEmails(); if (config.emails.length === 0 || !engine || typeof engine.find !== 'function') return 'unknown'; const SYSTEM = { context: { isSystem: true } }; - const PROBE_LIMIT = 50; const asRows = (raw: unknown): Record[] => { if (Array.isArray(raw)) return raw as Record[]; const records = (raw as { records?: unknown } | null | undefined)?.records; @@ -256,9 +264,15 @@ export async function probeWalledOwnerAccountState( if (owners.length > 0) { return owners.some(isEmailVerifiedUserRow) ? 'owner-verified' : 'owner-unverified'; } - const page = asRows(await engine.find(SystemObjectName.USER, { limit: PROBE_LIMIT }, SYSTEM)); - const humansExist = page.some(isHumanUserRow) || page.length >= PROBE_LIMIT; - return humansExist ? 'owner-absent' : 'no-human-users'; + // [#14353] ONE human-population page read per boot. The caller's + // `kernel:ready` hook already needs this fact for the sign-in-reachability + // report, so it hands the answer in and the store is not paged twice; a + // caller that has not read it (every direct caller, and every test) + // passes nothing and the probe reads it here. Either way the predicate is + // `isHumanUserRow` and the page semantics are the family's own. + const humans = known?.humanUsers ?? (await probeHumanUsersPresence(engine)); + if (humans === 'unknown') return 'unknown'; + return humans === 'present' ? 'owner-absent' : 'no-human-users'; } catch { return 'unknown'; } From 6fb31fb2f87fc409eedccba5e4e90a31c363bcd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 05:54:12 +0000 Subject: [PATCH 2/5] test(plugin-auth): pin the zero-account boot report, its controls and its independence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the dead-end shape, all four silent controls, the error level with its `warn` fallback, the boot wiring, independence from each of the walled-owner probe's four preconditions, the one-report-per-boot precedence, and that `sys_user` is paged exactly once per boot. Measured while writing this: breaking the declared-owner precondition alone is an unreachable boot — a walled posture with no declared owner refuses startup in `init()` (#11184) — so the reachable no-owner shape is the default deployment, which is what the suite pins. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --- .../src/boot-sign-in-reachability.test.ts | 467 ++++++++++++++++++ 1 file changed, 467 insertions(+) create mode 100644 packages/plugins/plugin-auth/src/boot-sign-in-reachability.test.ts diff --git a/packages/plugins/plugin-auth/src/boot-sign-in-reachability.test.ts b/packages/plugins/plugin-auth/src/boot-sign-in-reachability.test.ts new file mode 100644 index 0000000000..bd570e498a --- /dev/null +++ b/packages/plugins/plugin-auth/src/boot-sign-in-reachability.test.ts @@ -0,0 +1,467 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#14353] The boot-time report for a deployment that seeded people and no + * logins — human `sys_user` rows, zero `sys_account` rows. + * + * ⛔ Nothing here may become a refusal, and nothing here changes an admission + * decision: boot proceeds in EVERY shape below, including the one that + * reports. #14349's posture question (should the bootstrap carve-out count + * humans or logins) was ruled A on 2026-09-02 — the door does not move — and + * this suite asserts only what is REPORTED. + * + * The load-bearing half of this file is the CONTROLS. A report that fires on + * every boot satisfies "the dead-end shape reports" just as well as a correct + * one does, so each neighbouring shape is pinned SILENT: an account exists, no + * humans exist, and either fact unanswerable. + * + * The other load-bearing half is INDEPENDENCE. The neighbouring [#11640] + * walled-owner reporter only runs when all four of {no email transport, no + * federated sign-in, walled tenancy posture, declared platform owner} hold. + * This report shares that hook but none of those preconditions, and the + * `independent of the walled-owner preconditions` describe pins that as + * behaviour rather than as prose — a deployment that is unwalled, declares no + * owner, or wires an email transport is just as unrecoverable and must still + * be told. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AuthPlugin } from './auth-plugin'; +import { + NO_SIGN_IN_ACCOUNT_AT_BOOT, + HUMAN_POPULATION_PROBE_LIMIT, + probeHumanUsersPresence, + probeSignInAccountsPresence, + probeSignInReachability, + resolveNoSignInAccountReport, + reportIfNoSignInAccountExists, + type BootProbeEngine, + type SignInReachabilityFacts, +} from './boot-sign-in-reachability'; +import { WALLED_OWNER_NO_VERIFICATION_PATH } from './walled-owner-verification-path'; +import type { PluginContext } from '@objectstack/core'; + +const DEAD_END: SignInReachabilityFacts = { humanUsers: 'present', signInAccounts: 'absent' }; + +const ENV_KEYS = [ + 'OS_TENANCY_POSTURE', + 'OS_MULTI_ORG_ENABLED', + 'OS_PLATFORM_OWNER_EMAIL', + 'OS_SEED_ADMIN', + 'OS_SEED_ADMIN_EMAIL', + 'OS_SSO_ENABLED', + 'GOOGLE_CLIENT_ID', + 'GOOGLE_CLIENT_SECRET', + 'NODE_ENV', +] as const; +const SAVED: Record = {}; +beforeEach(() => { + for (const k of ENV_KEYS) { + SAVED[k] = process.env[k]; + delete process.env[k]; + } +}); +afterEach(() => { + for (const k of ENV_KEYS) { + if (SAVED[k] === undefined) delete process.env[k]; + else process.env[k] = SAVED[k]; + } +}); + +// --------------------------------------------------------------------------- +// A fake store that RECORDS its reads — the page-read count is an assertion +// here, not an implementation detail (the addendum forbids a second prober +// that duplicates the page read, and only a call count can pin that). +// --------------------------------------------------------------------------- + +type Store = { users?: Record[]; accounts?: Record[] }; + +const engineOver = (store: Store, opts: { throwOn?: string } = {}) => { + const reads: { object: string; query: Record }[] = []; + const engine: BootProbeEngine = { + async find(object, query) { + reads.push({ object, query }); + if (opts.throwOn === object) throw new Error(`store refused ${object}`); + const rows = object === 'sys_user' ? (store.users ?? []) : (store.accounts ?? []); + const limit = typeof query.limit === 'number' ? query.limit : rows.length; + return rows.slice(0, limit); + }, + }; + return { engine, reads }; +}; + +const human = (i: number) => ({ id: `usr_${i}`, email: `person${i}@corp.example`, role: 'user' }); +const HUMANS = [human(1), human(2), human(3)]; + +// --------------------------------------------------------------------------- +// The predicate +// --------------------------------------------------------------------------- + +describe('#14353 — the dead-end shape reports, by name, with consequence AND remedy', () => { + it('humans present + zero accounts produces the named report', () => { + const msg = resolveNoSignInAccountReport(DEAD_END); + expect(msg).toBeTruthy(); + expect(msg).toContain(NO_SIGN_IN_ACCOUNT_AT_BOOT); + // Names both tables, so an operator reading one line knows what to look at. + expect(msg).toContain('sys_user'); + expect(msg).toContain('sys_account'); + }); + + it('the report NAMES THE CONSEQUENCE — unrecoverable, and healthy-looking', () => { + const msg = resolveNoSignInAccountReport(DEAD_END)!; + expect(msg).toContain('NOBODY CAN SIGN IN'); + expect(msg).toContain('CANNOT BE RECOVERED FROM INSIDE'); + // The silent half: the deployment keeps LOOKING fine, which is why this + // has to be said at boot rather than left to the 401. + expect(msg.toLowerCase()).toContain('looking healthy'); + expect(msg).toContain('401'); + }); + + it('the report NAMES BOTH REMEDIES — the card is only closed if it is actionable', () => { + const msg = resolveNoSignInAccountReport(DEAD_END)!; + expect(msg).toContain('PROVISION AN ACCOUNT OUT OF BAND'); + expect(msg).toContain('OPEN THE AUDIENCE POSTURE'); + // Real posture spellings from the spec vocabulary, not invented ones. + expect(msg).toContain("'open'"); + expect(msg).toContain("'email_domain'"); + }); + + it('names WHY the bootstrap carve-out does not rescue this deployment', () => { + // The #14349 ruling (option A, 2026-09-02) licensed saying plainly that + // the door stays shut; a remedy clause that implied the next visitor + // would be admitted would be wrong under the ruling as given. + const msg = resolveNoSignInAccountReport(DEAD_END)!; + expect(msg).toContain('counts HUMANS'); + expect(msg).toContain('SELF_REGISTRATION_CLOSED'); + expect(msg).toContain('invite_only'); + }); +}); + +describe('#14353 — the controls: every other shape is SILENT', () => { + it('NEGATIVE CONTROL — an account exists ⇒ silent', () => { + expect( + resolveNoSignInAccountReport({ humanUsers: 'present', signInAccounts: 'present' }), + ).toBeNull(); + }); + + it('no human users ⇒ silent (a fresh store is healthy; its bootstrap is still ahead)', () => { + expect( + resolveNoSignInAccountReport({ humanUsers: 'absent', signInAccounts: 'absent' }), + ).toBeNull(); + }); + + it('an unanswerable human read ⇒ silent — this report makes a positive claim or none', () => { + expect( + resolveNoSignInAccountReport({ humanUsers: 'unknown', signInAccounts: 'absent' }), + ).toBeNull(); + }); + + it('an unanswerable account read ⇒ silent', () => { + expect( + resolveNoSignInAccountReport({ humanUsers: 'present', signInAccounts: 'unknown' }), + ).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// The probes +// --------------------------------------------------------------------------- + +describe('#14353 — the probe reads humans, not rows', () => { + it('a store holding only the legacy `usr_system` service row has NO humans', async () => { + const { engine } = engineOver({ users: [{ id: 'usr_system', role: 'system' }] }); + await expect(probeHumanUsersPresence(engine)).resolves.toBe('absent'); + }); + + it('a full page of non-humans reads as POPULATED — it cannot prove absence', async () => { + const users = Array.from({ length: HUMAN_POPULATION_PROBE_LIMIT }, () => ({ + id: 'usr_system', + role: 'system', + })); + const { engine } = engineOver({ users }); + await expect(probeHumanUsersPresence(engine)).resolves.toBe('present'); + }); + + it('ANY `sys_account` row counts — provider, issuer and ban state are not asked about', async () => { + const { engine } = engineOver({ accounts: [{ id: 'acc_1', provider_id: 'anything' }] }); + await expect(probeSignInAccountsPresence(engine)).resolves.toBe('present'); + }); + + it('no engine ⇒ `unknown` on both facts, never a false `absent`', async () => { + await expect(probeHumanUsersPresence(undefined)).resolves.toBe('unknown'); + await expect(probeSignInAccountsPresence(undefined)).resolves.toBe('unknown'); + }); + + it('a store that throws ⇒ `unknown`, and the probe never throws', async () => { + const { engine } = engineOver({ users: HUMANS }, { throwOn: 'sys_user' }); + await expect(probeHumanUsersPresence(engine)).resolves.toBe('unknown'); + }); + + it('the reachability pass skips the account read when no human was seen', async () => { + const { engine, reads } = engineOver({ users: [], accounts: [] }); + await expect(probeSignInReachability(engine)).resolves.toEqual({ + humanUsers: 'absent', + signInAccounts: 'unknown', + }); + expect(reads.map((r) => r.object)).toEqual(['sys_user']); + }); + + it('the reachability pass reads BOTH tables once when humans exist', async () => { + const { engine, reads } = engineOver({ users: HUMANS, accounts: [] }); + await expect(probeSignInReachability(engine)).resolves.toEqual({ + humanUsers: 'present', + signInAccounts: 'absent', + }); + expect(reads.map((r) => r.object)).toEqual(['sys_user', 'sys_account']); + // Bounded: one page of users, one row of accounts. + expect(reads[0].query.limit).toBe(HUMAN_POPULATION_PROBE_LIMIT); + expect(reads[1].query.limit).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// The emitter and its sink +// --------------------------------------------------------------------------- + +describe('#14353 — the emitter logs ONCE, at `error`, and survives a broken sink', () => { + it('reports at `error` — the consequence is unrecoverable, not degraded', () => { + const logger = { warn: vi.fn(), error: vi.fn(), info: vi.fn() }; + const returned = reportIfNoSignInAccountExists(DEAD_END, logger); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(String(logger.error.mock.calls[0][0])).toContain(NO_SIGN_IN_ACCOUNT_AT_BOOT); + expect(returned).toBe(logger.error.mock.calls[0][0]); + // Not both channels — one report, one line. + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('a sink that declares only `warn` still HEARS this — the fallback is explicit', () => { + // The #13398-class ruling forbids growing `error?` onto a published sink + // that lacks it. A bare `error?.(…)` against such a sink emits NOTHING, + // which would make this report silent on exactly the hosts that publish + // the narrower shape; the explicit branch is what stops that. + const warnOnly = { warn: vi.fn() }; + const returned = reportIfNoSignInAccountExists(DEAD_END, warnOnly); + expect(warnOnly.warn).toHaveBeenCalledTimes(1); + expect(String(warnOnly.warn.mock.calls[0][0])).toContain(NO_SIGN_IN_ACCOUNT_AT_BOOT); + expect(returned).toBeTruthy(); + }); + + it('a control shape logs nothing at all', () => { + const logger = { warn: vi.fn(), error: vi.fn() }; + expect( + reportIfNoSignInAccountExists({ humanUsers: 'present', signInAccounts: 'present' }, logger), + ).toBeNull(); + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('a logger that throws cannot break the boot', () => { + const logger = { error: () => { throw new Error('sink is down'); } }; + expect(() => reportIfNoSignInAccountExists(DEAD_END, logger)).not.toThrow(); + }); + + it('no logger at all is not an error', () => { + expect(reportIfNoSignInAccountExists(DEAD_END)).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// Wired at boot, not merely written. +// --------------------------------------------------------------------------- + +type Hooked = { event: string; handler: (...a: unknown[]) => unknown }; + +const makeCtx = (services: Record = {}) => { + const hooks: Hooked[] = []; + const logger = { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }; + const ctx = { + registerService: vi.fn(), + getService: vi.fn((name: string) => { + if (name === 'manifest') return { register: vi.fn() }; + if (name in services) return services[name]; + // The real `PluginContext.getService` THROWS on an unregistered name, + // and the hook under test wraps its `objectql` lookup in a try/catch + // for exactly that. Thrown here so the absence case exercises the + // catch; every other absence stays `undefined`, as the sibling + // walled-owner harness has it, because `init()` reads several. + if (name === 'objectql') throw new Error('no service objectql'); + return undefined; + }), + getServices: vi.fn(() => new Map()), + hook: vi.fn((event: string, handler: (...a: unknown[]) => unknown) => { + hooks.push({ event, handler }); + }), + trigger: vi.fn(), + logger, + getKernel: vi.fn(), + } as unknown as PluginContext; + return { ctx, hooks, logger }; +}; + +const runKernelReady = async (hooks: Hooked[]) => { + for (const h of hooks.filter((x) => x.event === 'kernel:ready')) { + // Sibling hooks need services this fake context does not carry; their + // failures are not this suite's subject. + try { await h.handler(); } catch { /* not under test */ } + } +}; + +const bootWith = async (store: Store, env: Record = {}) => { + for (const [k, v] of Object.entries(env)) process.env[k] = v; + const { engine, reads } = engineOver(store); + const { ctx, hooks, logger } = makeCtx({ objectql: engine }); + const plugin = new AuthPlugin({ + secret: 'test-secret-at-least-32-chars-long', + registerRoutes: false, + }); + await plugin.init(ctx); + await plugin.start(ctx); + await runKernelReady(hooks); + const said = (fn: { mock: { calls: unknown[][] } }) => fn.mock.calls.map((c) => String(c[0])); + return { logger, reads, errors: said(logger.error), warnings: said(logger.warn) }; +}; + +describe('#14353 — the report is wired into AuthPlugin boot', () => { + it('human rows and zero accounts emit the named ERROR from kernel:ready', async () => { + const { errors } = await bootWith({ users: HUMANS, accounts: [] }); + expect(errors.filter((m) => m.includes(NO_SIGN_IN_ACCOUNT_AT_BOOT))).toHaveLength(1); + }); + + it('NEGATIVE CONTROL — one account exists and the boot is silent', async () => { + const { errors, warnings } = await bootWith({ + users: HUMANS, + accounts: [{ id: 'acc_1', user_id: 'usr_1' }], + }); + expect(errors.filter((m) => m.includes(NO_SIGN_IN_ACCOUNT_AT_BOOT))).toHaveLength(0); + expect(warnings.filter((m) => m.includes(NO_SIGN_IN_ACCOUNT_AT_BOOT))).toHaveLength(0); + }); + + it('an empty store is silent — a fresh deployment is not a dead end', async () => { + const { errors } = await bootWith({ users: [], accounts: [] }); + expect(errors.filter((m) => m.includes(NO_SIGN_IN_ACCOUNT_AT_BOOT))).toHaveLength(0); + }); + + it('a boot with no `objectql` service is silent, and does not throw', async () => { + const { ctx, hooks, logger } = makeCtx(); + const plugin = new AuthPlugin({ + secret: 'test-secret-at-least-32-chars-long', + registerRoutes: false, + }); + await plugin.init(ctx); + await plugin.start(ctx); + await expect(runKernelReady(hooks)).resolves.toBeUndefined(); + expect( + logger.error.mock.calls.map((c) => String(c[0])).filter((m) => + m.includes(NO_SIGN_IN_ACCOUNT_AT_BOOT), + ), + ).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Independence — the reason this card is not subsumed by its neighbour. +// --------------------------------------------------------------------------- + +describe('#14353 — independent of ALL FOUR walled-owner preconditions', () => { + // The neighbour runs only when: no email transport, no federated sign-in, + // a walled tenancy posture, AND a declared platform owner. Each case below + // BREAKS one of those four and still demands the report. + + it('UNWALLED (`single`) — the neighbour never runs; this still reports', async () => { + const { errors, warnings } = await bootWith( + { users: HUMANS, accounts: [] }, + { OS_TENANCY_POSTURE: 'single', OS_PLATFORM_OWNER_EMAIL: 'owner@corp.example' }, + ); + expect(errors.filter((m) => m.includes(NO_SIGN_IN_ACCOUNT_AT_BOOT))).toHaveLength(1); + expect(warnings.filter((m) => m.includes(WALLED_OWNER_NO_VERIFICATION_PATH))).toHaveLength(0); + }); + + it('NO DECLARED OWNER, DEFAULT POSTURE — the plain deployment still reports', async () => { + // Measured while writing this suite: breaking the declared-owner + // precondition ALONE is an unreachable boot — a walled posture with + // `OS_PLATFORM_OWNER_EMAIL` unset REFUSES STARTUP in `init()` (#11184, + // pinned by `auth-plugin-walled-owner-boot-refusal.test.ts`), so there is + // no such deployment to diagnose. The reachable shape that carries no + // declared owner is the DEFAULT one — no tenancy posture, no owner, no + // transport, no SSO — which is also the commonest real deployment and the + // one the card's scenario was measured on. + const { errors, warnings } = await bootWith({ users: HUMANS, accounts: [] }); + expect(errors.filter((m) => m.includes(NO_SIGN_IN_ACCOUNT_AT_BOOT))).toHaveLength(1); + expect(warnings.filter((m) => m.includes(WALLED_OWNER_NO_VERIFICATION_PATH))).toHaveLength(0); + }); + + it('AN EMAIL TRANSPORT IS WIRED — the neighbour stays quiet; this still reports', async () => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + process.env.OS_PLATFORM_OWNER_EMAIL = 'owner@corp.example'; + const { engine } = engineOver({ users: HUMANS, accounts: [] }); + const { ctx, hooks, logger } = makeCtx({ + objectql: engine, + email: { send: vi.fn(), sendMail: vi.fn() }, + }); + const plugin = new AuthPlugin({ + secret: 'test-secret-at-least-32-chars-long', + registerRoutes: false, + }); + await plugin.init(ctx); + await plugin.start(ctx); + await runKernelReady(hooks); + const errors = logger.error.mock.calls.map((c) => String(c[0])); + const warnings = logger.warn.mock.calls.map((c) => String(c[0])); + expect(errors.filter((m) => m.includes(NO_SIGN_IN_ACCOUNT_AT_BOOT))).toHaveLength(1); + expect(warnings.filter((m) => m.includes(WALLED_OWNER_NO_VERIFICATION_PATH))).toHaveLength(0); + }); + + it('FEDERATED SIGN-IN IS WIRED — the neighbour stays quiet; this still reports', async () => { + const { errors, warnings } = await bootWith( + { users: HUMANS, accounts: [] }, + { + OS_TENANCY_POSTURE: 'isolated', + OS_PLATFORM_OWNER_EMAIL: 'owner@corp.example', + OS_SSO_ENABLED: '1', + }, + ); + expect(errors.filter((m) => m.includes(NO_SIGN_IN_ACCOUNT_AT_BOOT))).toHaveLength(1); + expect(warnings.filter((m) => m.includes(WALLED_OWNER_NO_VERIFICATION_PATH))).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// One report per boot, and one page read per boot. +// --------------------------------------------------------------------------- + +describe('#14353 — a deployment matching BOTH shapes gets exactly one report', () => { + it('the no-sign-in error fires and the walled-owner warning is SUPPRESSED', async () => { + // Walled, owner declared, nothing wired, humans present, zero accounts: + // the neighbour's `owner-absent` shape AND this card's shape at once. + const { errors, warnings } = await bootWith( + { users: HUMANS, accounts: [] }, + { OS_TENANCY_POSTURE: 'isolated', OS_PLATFORM_OWNER_EMAIL: 'owner@corp.example' }, + ); + expect(errors.filter((m) => m.includes(NO_SIGN_IN_ACCOUNT_AT_BOOT))).toHaveLength(1); + expect(warnings.filter((m) => m.includes(WALLED_OWNER_NO_VERIFICATION_PATH))).toHaveLength(0); + }); + + it('the neighbour is UNTOUCHED when this report did not fire', async () => { + // Same walled dead end, but an account exists — so only the neighbour + // has anything to say, and it still says it. + const { errors, warnings } = await bootWith( + { users: HUMANS, accounts: [{ id: 'acc_1' }] }, + { OS_TENANCY_POSTURE: 'isolated', OS_PLATFORM_OWNER_EMAIL: 'owner@corp.example' }, + ); + expect(errors.filter((m) => m.includes(NO_SIGN_IN_ACCOUNT_AT_BOOT))).toHaveLength(0); + expect(warnings.filter((m) => m.includes(WALLED_OWNER_NO_VERIFICATION_PATH))).toHaveLength(1); + }); + + it('`sys_user` is paged ONCE per boot even when BOTH probes need the answer', async () => { + // The addendum's hard constraint: no second prober duplicating the page + // read. The walled-owner probe takes the answer this pass already has. + const { reads } = await bootWith( + { users: HUMANS, accounts: [] }, + { OS_TENANCY_POSTURE: 'isolated', OS_PLATFORM_OWNER_EMAIL: 'owner@corp.example' }, + ); + const userPages = reads.filter( + (r) => r.object === 'sys_user' && r.query.limit === HUMAN_POPULATION_PROBE_LIMIT, + ); + expect(userPages).toHaveLength(1); + }); +}); From 0ba0851502ebaa2e7d6a7ffa5c7ebfae5ce7980b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 05:59:57 +0000 Subject: [PATCH 3/5] chore(changeset): patch changeset for the zero-account boot report Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --- .../auth-no-sign-in-account-boot-report.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .changeset/auth-no-sign-in-account-boot-report.md diff --git a/.changeset/auth-no-sign-in-account-boot-report.md b/.changeset/auth-no-sign-in-account-boot-report.md new file mode 100644 index 0000000000..0e2a84f96f --- /dev/null +++ b/.changeset/auth-no-sign-in-account-boot-report.md @@ -0,0 +1,28 @@ +--- +'@objectstack/plugin-auth': patch +--- + +Report the zero-account boot dead end at `kernel:ready` (#14353) + +A deployment holding human `sys_user` rows and zero `sys_account` rows cannot +be recovered from inside, and until now it booted silently. Nobody can sign in; +the first-account bootstrap carve-out counts humans, and humans exist, so it +does not open; the default `invite_only` audience posture refuses +self-registration; and no administrator exists who could send an invitation. +The only symptom was a 401 on credentials nobody holds. + +That state is now reported at `kernel:ready` at `error` level, under the name +`no_sign_in_account_at_boot`, naming both the consequence (the deployment will +keep looking healthy and cannot be recovered from inside) and the remedy +(provision an account out of band, or open the audience posture). + +⛔ No admission semantics change. Whether the carve-out should count humans or +logins was ruled on 2026-09-02 (option A — the door does not move); this only +reports. + +The check extends the existing `kernel:ready` walled-owner reporter rather than +opening a parallel one: it shares that hook, and the bounded human-population +page is read ONCE per boot and handed to `probeWalledOwnerAccountState`, so no +deployment pages `sys_user` twice. At most one report is emitted per boot — a +deployment matching both shapes gets this error, and the walled-owner warning +is suppressed rather than stacked on top of it. From d1c6ccca1b164eb946bcd4db8d05ee660d3caedf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 06:13:52 +0000 Subject: [PATCH 4/5] docs(permissions): re-anchor the system-context census row past the new boot check `check:check-system-context-census` caught the line rot my kernel:ready edit caused: the session-resolution elevation read moved from auth-plugin.ts:1380 to :1405. Re-anchored by the gate's own --fix; no census row's meaning changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --- content/docs/permissions/system-context.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 7ac4dc3ded..52971f07f9 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -97,7 +97,7 @@ that silently does not happen. | 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3857` | | 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` | | 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1015` | -| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1380` | +| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1405` | | 12 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `perf-timing.ts:474` | | 13 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `permission-set-overlay-discard.ts:142` | | 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` | From e10ae5955372e4f4b43ee91a7cea73d0b1b6c765 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 07:04:41 +0000 Subject: [PATCH 5/5] fix(plugin-auth): make the boot diagnostic sink's `warn` channel required MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:optional-error-sink` (gate #9754) failed on this branch: with both `error?` and `warn?` optional, `BootDiagnosticLogger` was a type every value of which may print nothing, so the contract permitted silence even though the emitter's explicit fallback branch was careful. Take the gate's own prescribed fix — `warn` becomes required, `error` stays optional. The two shapes the gate forbids are NOT taken: `error` is not made required (falsified, hosts inject reduced sinks), and this is not satisfied with a required `info` (a lost sign-in path reported at `info` is the reassuring half-truth AGENTS.md "Degradation log levels" removes). Fallout, all of it inside this module's own surface: - the emit branch drops its now-dead `?.` on `warn`; the type guarantees the channel, and the surrounding try/catch still holds for a throwing sink. - the "a logger that throws cannot break the boot" double no longer satisfied the type. It carries a real `vi.fn()` warn rather than a cast — a cast would re-open exactly the hole the gate closes — and now also pins that `warn` stays untouched when `error` is present and throws. The host call site is unaffected: `ctx.logger` is the spec `Logger`, whose `warn` is already required. Gate census moves by exactly one, in the intended direction: sinks declaring an optional `error` beside a REQUIRED `warn` 30 -> 31, sinks permitting silence 2 -> 1 (the remainder is the pre-existing baselined one, shrink-only). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --- .../src/boot-sign-in-reachability.test.ts | 14 ++++++++++- .../src/boot-sign-in-reachability.ts | 24 +++++++++++++------ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/plugins/plugin-auth/src/boot-sign-in-reachability.test.ts b/packages/plugins/plugin-auth/src/boot-sign-in-reachability.test.ts index bd570e498a..e0eaf4d9a5 100644 --- a/packages/plugins/plugin-auth/src/boot-sign-in-reachability.test.ts +++ b/packages/plugins/plugin-auth/src/boot-sign-in-reachability.test.ts @@ -256,8 +256,20 @@ describe('#14353 — the emitter logs ONCE, at `error`, and survives a broken si }); it('a logger that throws cannot break the boot', () => { - const logger = { error: () => { throw new Error('sink is down'); } }; + // `warn` is REQUIRED by the sink contract (#9754), so the double carries a + // real one rather than a cast — a cast here would re-open exactly the hole + // `check:optional-error-sink` closes. The throwing `error` is still the + // channel this case exercises: the emitter picks `error` when present, so + // `warn` must stay untouched while the boot survives. + const warn = vi.fn(); + const logger = { + warn, + error: () => { + throw new Error('sink is down'); + }, + }; expect(() => reportIfNoSignInAccountExists(DEAD_END, logger)).not.toThrow(); + expect(warn).not.toHaveBeenCalled(); }); it('no logger at all is not an error', () => { diff --git a/packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts b/packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts index 3ed72a2923..7894ce31f5 100644 --- a/packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts +++ b/packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts @@ -44,8 +44,8 @@ * * The #13398-class ruling caps this, and is satisfied rather than dodged: * what it forbids is GROWING `error?` onto a published sink that lacks it. - * {@link BootDiagnosticLogger} declares `error?` AND `warn?` from birth and - * nothing is widened — in particular the neighbouring + * {@link BootDiagnosticLogger} declares `error?` AND a required `warn` from + * birth and nothing is widened — in particular the neighbouring * `WalledOwnerVerificationLogger` (a `warn?`-only sink) is untouched. Spelled * the `share-link-service.ts` way: a conditional `error?.(…)` against a host * sink without `error` emits nothing, so the `warn` fallback is an explicit @@ -230,13 +230,23 @@ export function resolveNoSignInAccountReport(facts: SignInReachabilityFacts): st /** * The `error` channel this report needs, with the `warn` fallback the - * #13398-class ruling requires of a sink that may not declare `error`. Both - * members are optional and both are declared HERE, at birth: no published sink - * is widened by this module. + * #13398-class ruling requires of a sink that may not declare `error`. + * + * `warn` is REQUIRED and `error` is optional, which is the #9754 shape + * (`check:optional-error-sink`): the fallback channel a durability report + * degrades to must be present in EVERY value of the type, or the type still + * permits a sink that prints nothing and the guarantee lives only in this + * module's call branch. Making `error` required instead is the falsified + * option — hosts do inject reduced sinks — and a required `info` would not + * do: a lost sign-in path reported at `info` is the reassuring half-truth + * AGENTS.md → "Degradation log levels" exists to remove. + * + * Both members are still declared HERE, at birth: no published sink is + * widened by this module. */ export interface BootDiagnosticLogger { error?(message: string, ...rest: unknown[]): void; - warn?(message: string, ...rest: unknown[]): void; + warn(message: string, ...rest: unknown[]): void; } /** @@ -264,7 +274,7 @@ export function reportIfNoSignInAccountExists( // An `error?.(…)` against a sink without `error` emits NOTHING, so the // `warn` fallback is an explicit branch rather than an optional call. if (logger?.error) logger.error(message); - else logger?.warn?.(message); + else logger?.warn(message); } catch { /* a logger that throws must not abort the boot */ }