From 239578b37b48a0c3a959d4119d8fd7d6fa27afde Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 02:16:07 +0000 Subject: [PATCH 1/4] wip(plugin-auth): gate the dev-admin seed on a login, not on user rows Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --- packages/cli/src/commands/dev.ts | 15 +- .../plugins/plugin-auth/src/auth-manager.ts | 135 ++++- .../plugins/plugin-auth/src/auth-plugin.ts | 102 ++-- .../dev-admin-seed-credential-gate.test.ts | 487 ++++++++++++++++++ .../plugin-auth/src/dev-admin-seed-gate.ts | 162 ++++++ 5 files changed, 855 insertions(+), 46 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/dev-admin-seed-credential-gate.test.ts create mode 100644 packages/plugins/plugin-auth/src/dev-admin-seed-gate.ts diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index fb77bfa5e9..1ce8d8b7a2 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -139,7 +139,7 @@ export default class Dev extends Command { default: false, }), 'seed-admin': Flags.boolean({ - description: 'Seed a known, loginable dev admin (admin@objectos.ai / admin123) in-process via the runtime on an EMPTY DB, then promote it to platform admin. Default: on (idempotent — only acts on a zero-user DB, never overwrites an existing account). Disable with --no-seed-admin.', + description: 'Seed a known, loginable dev admin (admin@objectos.ai / admin123) in-process via the runtime when the database carries NO LOGIN yet, then promote it to platform admin. Default: on (idempotent — it acts only while no account holds the seed address and no local password login exists anywhere, never overwrites an existing account). Disable with --no-seed-admin.', allowNo: true, }), 'admin-email': Flags.string({ @@ -331,11 +331,14 @@ export default class Dev extends Command { // loader off. // ── Dev admin seeding (in-process) ────────────────────────────── // Seeding is performed IN-PROCESS by the runtime - // (@objectstack/plugin-auth → maybeSeedDevAdmin) on an empty DB — no - // HTTP POST, no port, no readiness race. The CLI's only job is to pass - // the toggle + credentials through to the serve child via env. - // Default ON in dev; `--no-seed-admin` disables it. The seed is - // idempotent (empty-DB only) and never overwrites an existing account. + // (@objectstack/plugin-auth → maybeSeedDevAdmin) — no HTTP POST, no + // port, no readiness race. The CLI's only job is to pass the toggle + + // credentials through to the serve child via env. Default ON in dev; + // `--no-seed-admin` disables it. [#14157] The seed is idempotent and + // gated on the absence of a LOGIN (no account on the seed address, no + // local password login anywhere) rather than on an empty user table — + // an app that declares people in `defineStack({ data })` fills that + // table before the seed runs. It never overwrites an existing account. const seedAdmin = flags['seed-admin'] ?? true; // Resolve the database through the ONE shared resolution (#6469) — diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 59018bece8..7f729659d8 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -18,6 +18,7 @@ import { isHumanUserRow, resolveAudience, AUDIENCE_CONFIG_ERROR, + type AudienceCreationClass, type ResolvedAudience, } from './audience-posture.js'; import { shouldStampOwnerVerifiedAtCreation } from './walled-owner-operator-stamp.js'; @@ -1055,10 +1056,11 @@ export class AuthManager { /** * Result of the dev-only admin seed (set by `AuthPlugin.maybeSeedDevAdmin` - * when it provisions the well-known admin on an empty DB). The `serve` - * command reads this after boot to surface the credentials in the startup - * banner. Undefined when no seed ran (production, opt-out, or a DB that - * already had a user). + * when it provisions the well-known admin, or re-armed on a later boot while + * that account still carries the default password). The `serve` command + * reads this after boot to surface the credentials in the startup banner. + * Undefined when no seed ran (production, opt-out, or [#14157] a database + * that already carries a login). */ public devSeedResult?: { email: string; password: string }; @@ -1951,7 +1953,14 @@ export class AuthManager { // `catch` read that refusal as "users exist" and the declared // bypass never fired on a real deployment. `isBootstrapCreation` // owns the answerable form — see its doc. - if (await this.isBootstrapCreation()) { + // [#14157] …and the deployment's own boot command provisioning its + // admin is the OTHER way through this door. It is not a + // self-registration and must not depend on a self-registration + // carve-out: an app that seeds people makes the bootstrap probe + // answer "populated" before the seed ever runs. Cheap synchronous + // check first, so the ticket path costs no I/O. + const signUpEmail = typeof ctx?.body?.email === 'string' ? ctx.body.email : undefined; + if (this.isOperatorProvisioning(signUpEmail) || (await this.isBootstrapCreation())) { ctx.context.__osDisableSignUpOrig = ep.disableSignUp; ep.disableSignUp = false; } @@ -3661,6 +3670,20 @@ export class AuthManager { private static readonly OWNER_STAMP_STAGE_TTL_MS = 10 * 60 * 1000; + /** + * [#14157] Addresses the deployment's OWN boot command is provisioning right + * now — see {@link stageOperatorProvisioning} for why this exists and why it + * is not the bootstrap probe. + */ + private pendingOperatorProvisioning = new Map(); + + /** + * Deliberately short. The window this covers is a single in-process + * `signUpEmail` call that the caller also clears in a `finally`; the TTL is + * only the floor under a caller killed between the two. + */ + private static readonly OPERATOR_PROVISIONING_STAGE_TTL_MS = 60 * 1000; + /** * Page size of the bootstrap population probe ({@link isBootstrapCreation}). * Matches the bound the dev-admin seed reads with, so the two ask the same @@ -3747,10 +3770,17 @@ export class AuthManager { // EXISTENCE. if (data?.source?.action !== 'create-user') return undefined; const audience = this.getAudience(); - const creationClass = classifyCreationMethod(data?.source, { - enterpriseOAuthProviderIds: this.enterpriseOAuthProviderIds(), - }); const email = typeof data?.user?.email === 'string' ? (data.user.email as string) : undefined; + // [#14157] A creation the deployment's own boot command staged is the + // OPERATOR class — the vendor's `source.method` cannot carry that, + // because the seed reaches better-auth through the same `signUpEmail` + // API a person's sign-up does. See `stageOperatorProvisioning` for why + // this is a declared ticket and not a wider bootstrap probe. + const creationClass: AudienceCreationClass = this.isOperatorProvisioning(email) + ? 'operator' + : classifyCreationMethod(data?.source, { + enterpriseOAuthProviderIds: this.enterpriseOAuthProviderIds(), + }); let isBootstrap = false; let hasPendingInvitation = false; if (creationClass === 'self-serve') { @@ -4030,6 +4060,95 @@ export class AuthManager { } } + /** + * [#14157] Declare that the deployment's own boot command is provisioning + * this address right now, so its creation is judged as the **operator** + * class it actually is rather than as a self-registration. + * + * ## Why this exists instead of widening the bootstrap probe + * + * The dev-admin seed provisions through the real `signUpEmail` pipeline (it + * must: that is what produces a hashed credential and runs the sign-up + * hooks), and until now its admission rode on + * {@link isBootstrapCreation} — "zero HUMAN users". That is a **public + * self-registration carve-out**: it is what lets an unknown visitor's first + * sign-up through under `invite_only`. Its population predicate therefore + * has to keep counting humans, because the population it is protecting + * against is humans. + * + * The seed's precondition is a different question — "does a LOGIN exist?" + * (`dev-admin-seed-gate.ts`) — and #14157 is what happens when the two are + * conflated: an app that seeds people in `defineStack({ data })` makes the + * database non-zero-user before the seed runs, and the dev admin is never + * minted. Fixing only the seed's own gate does not help, because the + * admission still refuses: **measured**, with 13 seeded people and zero + * accounts, `api.signUpEmail` comes back `SELF_REGISTRATION_CLOSED` under + * the default `invite_only` posture. + * + * So the seed says what it *is* instead of inferring admission from a + * population it does not own. That is the same claim + * `walled-owner-operator-stamp.ts` already makes about this account — + * "provisioned by the deployment's own boot command with operator-known + * credentials" — and it moves no public door: nothing outside this process + * can stage a ticket, the only caller is hard-gated to + * `NODE_ENV==='development'`, the ticket names ONE address, the caller + * clears it in a `finally`, and anything that outlives that is pruned by + * {@link OPERATOR_PROVISIONING_STAGE_TTL_MS}. + */ + stageOperatorProvisioning(email: string): void { + this.prunePendingOperatorProvisioning(); + this.pendingOperatorProvisioning.set(email.trim().toLowerCase(), { stagedAtMs: Date.now() }); + } + + /** [#14157] Drop the ticket staged by {@link stageOperatorProvisioning}. */ + clearOperatorProvisioning(email: string): void { + this.pendingOperatorProvisioning.delete(email.trim().toLowerCase()); + } + + /** + * [#14157] Is this address being provisioned by the deployment's own boot + * command? A PEEK, not a consume: both admission seams (the `disableSignUp` + * bypass and `validateAudienceAdmission`) ask about the same single + * creation, so a one-shot read here would admit at the first seam and refuse + * at the second. The ticket's lifetime is bounded by its owner's `finally` + * and by the TTL instead. + */ + isOperatorProvisioning(email: unknown): boolean { + if (typeof email !== 'string' || email.trim() === '') return false; + this.prunePendingOperatorProvisioning(); + return this.pendingOperatorProvisioning.has(email.trim().toLowerCase()); + } + + private prunePendingOperatorProvisioning(): void { + const cutoff = Date.now() - AuthManager.OPERATOR_PROVISIONING_STAGE_TTL_MS; + for (const [key, value] of this.pendingOperatorProvisioning) { + if (value.stagedAtMs < cutoff) this.pendingOperatorProvisioning.delete(key); + } + } + + /** + * [#14157] The bootstrap window, as a PUBLIC read — "can a first-run owner + * still be created here?". + * + * `GET /auth/bootstrap-status` answers the console's first-run routing + * question, and it used to answer it by counting `sys_user` rows, which + * makes it the one call site that disagrees with the three + * {@link isHumanUserRow} consumers: on a database still carrying the legacy + * `usr_system` service row it reports an owner while the audience gate and + * plugin-security's first-user detection both say the first human is still + * ahead — i.e. the console withholds the setup flow that the platform is + * standing ready to admit and promote. Reading THIS method keeps the console + * from ever offering a first-run creation the admission gate would refuse, + * and from ever hiding one it would allow. + * + * Never throws; an unanswerable probe reads as "no window" — the same + * fail-closed direction the admission gate takes, and the same answer the + * route's own catch produced. + */ + async hasBootstrapWindow(): Promise { + return this.isBootstrapCreation(); + } + private stageSelfRegistrationGrant(email: string, setName: string): void { this.prunePendingSelfRegistrationGrants(); this.pendingSelfRegistrationGrants.set(email.trim().toLowerCase(), { diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index f60d01c641..8d8137058e 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -13,9 +13,14 @@ import { type SettingsUnsubscribe, SystemObjectName, } from '@objectstack/spec/system'; -// [#11767] The shared bootstrap population predicate — the dev-admin seed and -// the audience gate's bootstrap bypass must answer the same question. +// [#11767] The shared bootstrap population predicate — the audience gate's +// bootstrap bypass and plugin-security's first-user detection must answer the +// same question. [#14157] The dev-admin seed no longer GATES on it (a +// directory row is not a login); it still reads it to find the seed account +// among existing users when re-arming the credential hint. import { isHumanUserRow } from './audience-posture.js'; +// [#14157] The dev-admin seed's own precondition — "does a login exist?". +import { decideDevAdminSeedGate } from './dev-admin-seed-gate.js'; import { // ADR-0048 — the Setup/Studio/Account apps moved to their own packages // (@objectstack/{setup,studio,account}); plugin-auth no longer registers them. @@ -1725,9 +1730,15 @@ export class AuthPlugin implements Plugin { * un-loginable row. * Running it in-process needs no port and no readiness polling. * - * Idempotent and non-destructive: it only ever acts on a zero-user DB and - * never touches an existing account, so a custom password is never - * overwritten. + * Idempotent and non-destructive: [#14157] it only ever acts on a database + * with NO LOGIN — no account on the configured address, and no local + * password login anywhere — and never touches an existing account, so a + * custom password is never overwritten. The predicate used to be "no human + * `sys_user` row", which is the same question only while every user row + * carries a credential: an app declaring people in `defineStack({ data })` + * made the database non-zero-user before this hook ran, and the admin was + * never minted — on that boot or any later one. `dev-admin-seed-gate.ts` + * owns the predicate and the argument. * * HARD-GATED to development (NODE_ENV==='development'): a known-credential * admin can never be provisioned in production. Opt out within dev via @@ -1751,25 +1762,33 @@ export class AuthPlugin implements Plugin { if (!ql || typeof ql.find !== 'function') return; try { - // Only seed when no HUMAN user exists yet. A DB created by an older - // runtime may still contain the system service account - // (SystemUserId.SYSTEM, role='system'), which must NOT count — mirror - // plugin-security's first-user detection so the seed fires on a - // genuinely empty DB. Any real human user (or a prior sign-up) disables - // the seed for good; we never touch or overwrite an existing account. - // - // [#11767] The predicate itself is `isHumanUserRow`, shared with the - // audience gate's bootstrap bypass (`AuthManager.isBootstrapCreation`) - // — this seed's `signUpEmail` call passes through that gate, so the two - // MUST answer the same question. Two hand-spelled copies is how they - // drift, and a drift there means a seed that decides to run and a gate - // that then refuses it. + // The human page is read for the REPORT path below, which looks the + // seed account up among the existing users. [#11767] `isHumanUserRow` + // is the shared population predicate; it is no longer the seed's GATE. const rows = await ql .find(SystemObjectName.USER, { where: {}, limit: 50 }, { context: { isSystem: true } }) .catch(() => []); const humans = (Array.isArray(rows) ? rows : []).filter(isHumanUserRow); - if (humans.length > 0) { - ctx.logger.debug('[auth] dev admin seed skipped — a user already exists'); + + // [#14157] THE GATE: a LOGIN, not a directory row. See + // `dev-admin-seed-gate.ts` for why the predicate moved off the user + // table and why an unanswerable probe is its own verdict. + const verdict = await decideDevAdminSeedGate(ql, email); + if (!verdict.act) { + if (verdict.reason === 'unanswerable') { + // Functional degradation, and the operator has to be told: the DB + // could not be read, so the seed declined rather than minting a + // known-credential admin into an environment it cannot see. Nothing + // claims to have been persisted, so `warn` is the level (AGENTS.md + // → Degradation log levels). + ctx.logger.warn( + '[auth] dev admin seed skipped — the credential store could not be read, so whether a ' + + 'login already exists is unknown. No admin was provisioned; re-run once the data engine ' + + `is reachable, or provision ${email} yourself.`, + ); + return; + } + ctx.logger.debug(`[auth] dev admin seed skipped — ${verdict.reason}`); // `os dev` defaults to a persistent DB, so the seed fires exactly // once — but the startup banner and the Console login hint read // `devSeedResult`, which used to be set only on the seeding boot. @@ -1788,10 +1807,24 @@ export class AuthPlugin implements Plugin { } // Real auth pipeline: creates sys_user + a hashed `credential` account - // and runs the sign-up hooks. The dev-mode OS_DISABLE_SIGNUP bypass - // (auth-manager.ts) lets this through on an empty DB even when sign-up - // is otherwise disabled. - await api.signUpEmail({ body: { email, password, name } }); + // and runs the sign-up hooks. + // + // [#14157] Admission for THIS call is declared, not inferred. Both + // gates on the way in — the audience posture and the `disableSignUp` + // bypass — used to let the seed through only via the zero-human + // bootstrap probe, which an app's own `defineStack({ data })` people + // seed answers "populated" long before this hook runs: measured, the + // seed's `signUpEmail` comes back `SELF_REGISTRATION_CLOSED` under the + // default `invite_only` posture with 13 seeded people and zero + // accounts. The ticket says what this creation IS (the deployment's own + // boot command provisioning its admin — the operator class) for exactly + // this address, and is cleared whatever happens. + this.authManager.stageOperatorProvisioning(email); + try { + await api.signUpEmail({ body: { email, password, name } }); + } finally { + this.authManager.clearOperatorProvisioning(email); + } // [#11343] Stamp the seeded admin's address VERIFIED. This account is // provisioned by the deployment's own boot command with operator-known // credentials — it is not an unknown self-registrant, which is the class @@ -1975,16 +2008,21 @@ export class AuthPlugin implements Plugin { // /setup (first-run owner creation). Public, unauthenticated; only // returns a boolean so it can be polled before the user has any // credentials. + // + // [#14157] It asks `AuthManager.hasBootstrapWindow()` — the SAME question + // the audience gate's bootstrap bypass answers, so the console can never + // offer a first-run creation the platform would refuse, nor withhold one + // it would admit and promote. It used to count `sys_user` rows with no + // filter at all, which made it the one call site disagreeing with the + // three `isHumanUserRow` consumers: on a database still carrying the + // legacy `usr_system` service row it answered `hasOwner: true` while the + // admission gate and plugin-security's first-user detection both said the + // first human was still ahead. A no-engine composition (MSW/mock mode) + // still reads as bootstrapped — the probe answers "no window" without an + // engine — so the SPA falls through to its normal login flow as before. rawApp.get(`${basePath}/bootstrap-status`, async (c: any) => { try { - const dataEngine = this.authManager!.getDataEngine(); - if (!dataEngine) { - // No data engine wired (e.g. MSW/mock mode) — assume bootstrapped - // so the SPA falls through to its normal login flow. - return c.json({ hasOwner: true }); - } - const count = await dataEngine.count('sys_user', {}); - return c.json({ hasOwner: (count ?? 0) > 0 }); + return c.json({ hasOwner: !(await this.authManager!.hasBootstrapWindow()) }); } catch (error) { ctx.logger.warn('[AuthPlugin] bootstrap-status check failed; assuming bootstrapped', error as Error); return c.json({ hasOwner: true }); diff --git a/packages/plugins/plugin-auth/src/dev-admin-seed-credential-gate.test.ts b/packages/plugins/plugin-auth/src/dev-admin-seed-credential-gate.test.ts new file mode 100644 index 0000000000..15774a1b22 --- /dev/null +++ b/packages/plugins/plugin-auth/src/dev-admin-seed-credential-gate.test.ts @@ -0,0 +1,487 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#14157] The dev-admin seed fires on a database that has PEOPLE but no + * LOGIN — pinned end to end over a REAL `ObjectQL` engine. + * + * ## What shipped, and why every existing suite stayed green + * + * `objectstack dev` declares a known, loginable dev admin. The gate that + * implemented it asked "does any human `sys_user` row exist?", which is the + * same question only while every user row carries a credential. An app that + * declares people in `defineStack({ data })` breaks that equivalence: the + * declarative seed is awaited inside `AppPlugin.start()`, so it always lands + * before the seed's own `kernel:ready` hook, the database is non-zero-user + * before the check, and the admin is never minted — on that boot or any later + * one. Measured on a real app: 13 `sys_user` rows, **zero** `sys_account` + * rows, sign-in 401. Every unit suite stayed green because every fixture + * started from an empty table, which is the one population where the two + * predicates agree. + * + * ## Why the gate is only HALF the fix, and why case ⓪ is here + * + * The seed provisions through better-auth's real `signUpEmail`, and that call + * has to be ADMITTED. Its admission rode on the audience gate's bootstrap + * bypass — "zero HUMAN users" — which the same 13 rows also answer + * "populated". Case ⓪ measures that directly: with the seeded people in + * place and no operator ticket staged, the seed's own lane comes back + * `SELF_REGISTRATION_CLOSED` under the default `invite_only` posture. So a fix + * that moved only the gate would have produced a seed that decides to run and + * a gate that then refuses it. ⓪ is what keeps every case below non-vacuous: + * it proves the refusal is real, so ① is not passing for some unrelated reason. + * + * The second half is a DECLARED ticket (`stageOperatorProvisioning`), not a + * wider bootstrap probe — case ④ is that decision's pin: the public + * self-registration door must be exactly where it was, on exactly the + * population that made this card, before and after the seed runs. + * + * ## `bootstrap-status` (cases ⑦–⑧) + * + * The card flagged `hasOwner` as a look-alike. Measured, it is a real + * disagreement of its own: the handler counted `sys_user` rows with NO filter, + * so it was the one call site out of step with the three `isHumanUserRow` + * consumers — on a database carrying the legacy `usr_system` service row it + * answered "an owner exists" while the admission gate and plugin-security's + * first-user detection both stood ready to admit and promote the first human. + * It now reads the same bootstrap-window question the admission gate answers, + * so the console can never offer a first-run creation the platform refuses, + * nor withhold one it would allow. Driven through the REAL route, because the + * console reads the route and not the predicate. + */ + +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; +import { Hono } from 'hono'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import type { PluginContext } from '@objectstack/core'; +import { AuthManager } from './auth-manager.js'; +import { AuthPlugin } from './auth-plugin.js'; +import { SELF_REGISTRATION_CLOSED } from './audience-posture.js'; +import { decideDevAdminSeedGate } from './dev-admin-seed-gate.js'; +import { recoverInternalFieldsForSystemRead } from './internal-field-readback.js'; +import { + SysUser, + SysSession, + SysAccount, + SysVerification, + SysOrganization, + SysMember, + SysInvitation, + SysTeam, + SysTeamMember, +} from '@objectstack/platform-objects'; + +const BASE = 'http://localhost:3000'; +const AUTH_BASE = '/api/v1/auth'; +const SECRET = 'test-secret-at-least-32-chars-long-14157'; +const SEED_EMAIL = 'admin@objectos.ai'; +const SEED_PASSWORD = 'admin123'; +const SYSTEM = { context: { isSystem: true } } as never; + +const AUTH_OBJECTS = [ + SysUser, + SysSession, + SysAccount, + SysVerification, + SysOrganization, + SysMember, + SysInvitation, + SysTeam, + SysTeamMember, +]; + +/** The env the seed is HARD-gated on (`isDevAdminSeedArmed`). */ +const SEED_ENV_KEYS = [ + 'NODE_ENV', + 'OS_SEED_ADMIN', + 'OS_SEED_ADMIN_EMAIL', + 'OS_SEED_ADMIN_PASSWORD', + 'OS_SEED_ADMIN_NAME', +] as const; +let savedEnv: Record = {}; + +const engines: ObjectQL[] = []; + +beforeEach(() => { + savedEnv = Object.fromEntries(SEED_ENV_KEYS.map((k) => [k, process.env[k]])); + for (const k of SEED_ENV_KEYS) delete process.env[k]; + process.env.NODE_ENV = 'development'; +}); + +afterEach(async () => { + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + while (engines.length) { + const e = engines.pop(); + try { + await (e as unknown as { destroy?(): Promise })?.destroy?.(); + } catch { + /* noop */ + } + } +}); + +async function bootEngine(): Promise { + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver( + new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), + true, + ); + await engine.init(); + for (const object of AUTH_OBJECTS) { + engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); + } + await engine.syncSchemas(); + return engine; +} + +/** The population that makes this card: people, no credentials. */ +async function seedPeople(engine: ObjectQL, n: number): Promise { + for (let i = 0; i < n; i++) { + await engine.insert( + 'sys_user', + { name: `Person ${i}`, email: `person${i}@demo.example` }, + SYSTEM, + ); + } +} + +function makeManager(engine: ObjectQL, config: Record = {}): AuthManager { + return new AuthManager({ + secret: SECRET, + baseUrl: BASE, + dataEngine: engine as never, + ...config, + } as never); +} + +interface SeedRun { + plugin: AuthPlugin; + manager: AuthManager; + logs: { level: string; message: string }[]; +} + +/** + * Run exactly what `kernel:ready` runs — `AuthPlugin.maybeSeedDevAdmin` — over + * a real engine and a real `AuthManager`. Private by design (it is a boot + * step, not an API); reached the way the hook reaches it. + */ +async function runDevAdminSeed(engine: ObjectQL, manager?: AuthManager): Promise { + const mgr = manager ?? makeManager(engine); + const logs: { level: string; message: string }[] = []; + const record = (level: string) => (message: unknown) => + logs.push({ level, message: String(message) }); + const ctx = { + getService: (name: string) => (name === 'objectql' ? engine : undefined), + logger: { + info: record('info'), + warn: record('warn'), + error: record('error'), + debug: record('debug'), + }, + } as unknown as PluginContext; + + const plugin = new AuthPlugin({ secret: SECRET }); + (plugin as unknown as { authManager: AuthManager }).authManager = mgr; + await ( + plugin as unknown as { maybeSeedDevAdmin(c: PluginContext): Promise } + ).maybeSeedDevAdmin(ctx); + return { plugin, manager: mgr, logs }; +} + +async function readRows(engine: ObjectQL, object: string): Promise[]> { + const raw = await engine.find(object, { limit: 200 }, SYSTEM); + return (Array.isArray(raw) ? raw : []) as Record[]; +} + +/** + * Every `sys_account` row INCLUDING the credential hash — `password` is + * `internal: true`, so a system read arrives without it (#8676) and a + * "nothing changed" assertion that skipped it would be blind to exactly the + * column an overwrite would rewrite. + */ +async function readAccountsWithSecrets(engine: ObjectQL): Promise[]> { + const rows = await readRows(engine, 'sys_account'); + await recoverInternalFieldsForSystemRead(engine as never, 'sys_account', rows, ['password']); + return rows.sort((a, b) => String(a.id).localeCompare(String(b.id))); +} + +/** A stranger's PUBLIC self-registration — the door that must not move. */ +function httpSignUp(manager: AuthManager, email: string): Promise { + return manager.handleRequest( + new Request(`${BASE}${AUTH_BASE}/sign-up/email`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: BASE }, + body: JSON.stringify({ email, password: 'S3cure!Passw0rd-14157', name: 'Stranger' }), + }), + ); +} + +/** The real `GET /auth/bootstrap-status` route, mounted the way the plugin mounts it. */ +function mountBootstrapStatus(manager: AuthManager): Hono { + const app = new Hono(); + const ctx = { + registerService: vi.fn(), + getService: vi.fn(() => undefined), + getServices: vi.fn(() => new Map()), + hook: vi.fn(), + trigger: vi.fn(), + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + getKernel: vi.fn(), + } as unknown as PluginContext; + const plugin = new AuthPlugin({ secret: SECRET }); + (plugin as unknown as { authManager: AuthManager }).authManager = manager; + ( + plugin as unknown as { + registerAuthRoutes(s: unknown, c: PluginContext): void; + } + ).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx); + return app; +} + +async function bootstrapStatus(app: Hono): Promise<{ hasOwner: boolean }> { + const res = await app.request(`http://localhost${AUTH_BASE}/bootstrap-status`); + expect(res.status).toBe(200); + return (await res.json()) as { hasOwner: boolean }; +} + +describe('[#14157] the dev-admin seed gates on a LOGIN, not on user rows', () => { + it('⓪ THE MECHANISM: with people seeded and no ticket, the seed lane is REFUSED — so the gate alone could never have fixed this', async () => { + const engine = await bootEngine(); + await seedPeople(engine, 13); + const manager = makeManager(engine); + + // Exactly the call `maybeSeedDevAdmin` makes, with no operator ticket + // staged: the audience gate's bootstrap bypass reads these 13 rows as a + // populated environment and the default `invite_only` posture refuses. + const api = (await manager.getApi()) as unknown as { + signUpEmail(input: { body: Record }): Promise; + }; + let code = ''; + await expect( + api + .signUpEmail({ body: { email: SEED_EMAIL, password: SEED_PASSWORD, name: 'Dev Admin' } }) + .catch((e: { body?: { code?: string } }) => { + code = e?.body?.code ?? ''; + throw e; + }), + ).rejects.toBeTruthy(); + expect(code).toBe(SELF_REGISTRATION_CLOSED); + expect(await readRows(engine, 'sys_account')).toEqual([]); + }); + + it('① THE DEFECT: 13 seeded people and zero accounts — the dev admin IS provisioned, with a real credential', async () => { + const engine = await bootEngine(); + await seedPeople(engine, 13); + + const { manager } = await runDevAdminSeed(engine); + + const accounts = await readRows(engine, 'sys_account'); + expect( + accounts.map((a) => a.provider_id), + 'the seed must create exactly one local password login', + ).toEqual(['credential']); + const users = await readRows(engine, 'sys_user'); + const seeded = users.find((u) => String(u.email).toLowerCase() === SEED_EMAIL); + expect(seeded, 'the seed address must exist as a user').toBeTruthy(); + expect(accounts[0].user_id).toBe(seeded!.id); + // …and the row the account belongs to is stamped verified (#11343). + expect(seeded!.email_verified).toBeTruthy(); + // The banner the CLI prints reads this. + expect(manager.devSeedResult).toEqual({ email: SEED_EMAIL, password: SEED_PASSWORD }); + // The people the app seeded are untouched. + expect(users.filter((u) => String(u.email).endsWith('@demo.example')).length).toBe(13); + }); + + it('① (b) the provisioned admin can actually SIGN IN — the whole point of "loginable"', async () => { + const engine = await bootEngine(); + await seedPeople(engine, 13); + const { manager } = await runDevAdminSeed(engine); + + const res = await manager.handleRequest( + new Request(`${BASE}${AUTH_BASE}/sign-in/email`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: BASE }, + body: JSON.stringify({ email: SEED_EMAIL, password: SEED_PASSWORD }), + }), + ); + + expect(res.status, `sign-in failed: ${await res.clone().text()}`).toBeLessThan(300); + }); + + it('② NEVER OVERWRITE: a later boot leaves the existing account byte-identical, hash included', async () => { + const engine = await bootEngine(); + await seedPeople(engine, 13); + await runDevAdminSeed(engine); + const before = await readAccountsWithSecrets(engine); + expect(before.length).toBe(1); + expect(before[0].password, 'the hash must be readable, or this pin is blind').toBeTruthy(); + + // A second boot against the same persistent database. + const second = await runDevAdminSeed(engine); + + const after = await readAccountsWithSecrets(engine); + expect(after).toEqual(before); + expect(JSON.stringify(after)).toBe(JSON.stringify(before)); + // …and the credential hint is re-armed for the banner, not re-minted. + expect(second.manager.devSeedResult).toEqual({ + email: SEED_EMAIL, + password: SEED_PASSWORD, + }); + }); + + it('② (b) NEVER OVERWRITE: the seed address claimed by a FEDERATED account is left alone too', async () => { + const engine = await bootEngine(); + const user = (await engine.insert( + 'sys_user', + { name: 'Imported Admin', email: SEED_EMAIL }, + SYSTEM, + )) as Record; + await engine.insert( + 'sys_account', + { user_id: user.id, account_id: 'idp-123', provider_id: 'sso-oidc' }, + SYSTEM, + ); + + const { logs } = await runDevAdminSeed(engine); + + const accounts = await readRows(engine, 'sys_account'); + expect(accounts.map((a) => a.provider_id)).toEqual(['sso-oidc']); + expect(logs.some((l) => l.message.includes('seed-address-claimed'))).toBe(true); + }); + + it('③ CONTROL: a zero-user database still behaves exactly as it did', async () => { + const engine = await bootEngine(); + + const { manager } = await runDevAdminSeed(engine); + + expect((await readRows(engine, 'sys_account')).map((a) => a.provider_id)).toEqual([ + 'credential', + ]); + expect(manager.devSeedResult).toEqual({ email: SEED_EMAIL, password: SEED_PASSWORD }); + }); + + it('③ (b) CONTROL: an existing LOCAL login elsewhere still stops the seed — no second known-credential admin', async () => { + const engine = await bootEngine(); + const manager = makeManager(engine); + // A real operator signed up first, with their own address and password. + const api = (await manager.getApi()) as unknown as { + signUpEmail(input: { body: Record }): Promise; + }; + await api.signUpEmail({ + body: { email: 'boss@corp.example', password: 'S3cure!Passw0rd-14157', name: 'Boss' }, + }); + const before = await readAccountsWithSecrets(engine); + + const { logs } = await runDevAdminSeed(engine, makeManager(engine)); + + expect(await readAccountsWithSecrets(engine)).toEqual(before); + expect( + (await readRows(engine, 'sys_user')).some( + (u) => String(u.email).toLowerCase() === SEED_EMAIL, + ), + ).toBe(false); + expect(logs.some((l) => l.message.includes('local-login-exists'))).toBe(true); + }); + + it('④ CONTROL: the PUBLIC door did not move — a stranger is still refused on the same population', async () => { + const engine = await bootEngine(); + await seedPeople(engine, 13); + + // Before the seed runs… + const cold = makeManager(engine); + const coldRes = await httpSignUp(cold, 'stranger-before@example.com'); + expect(coldRes.status).toBe(403); + expect(((await coldRes.json()) as { code?: string }).code).toBe(SELF_REGISTRATION_CLOSED); + + const { manager } = await runDevAdminSeed(engine); + expect((await readRows(engine, 'sys_account')).length).toBe(1); + + // …and after it. The ticket admitted exactly one address, once. + const warmRes = await httpSignUp(manager, 'stranger-after@example.com'); + expect(warmRes.status).toBe(403); + expect(((await warmRes.json()) as { code?: string }).code).toBe(SELF_REGISTRATION_CLOSED); + expect((await readRows(engine, 'sys_account')).length).toBe(1); + }); + + it('⑤ NO RESIDUE: the operator ticket is gone once the seed returns', async () => { + const engine = await bootEngine(); + await seedPeople(engine, 13); + + const { manager } = await runDevAdminSeed(engine); + + expect(manager.isOperatorProvisioning(SEED_EMAIL)).toBe(false); + expect(manager.isOperatorProvisioning(SEED_EMAIL.toUpperCase())).toBe(false); + }); + + it('⑤ (b) the ticket is cleared even when the provisioning call THROWS', async () => { + const engine = await bootEngine(); + const manager = makeManager(engine); + // A user already holds the address but has no account at all, so the gate + // says "act" and better-auth then refuses the duplicate email. + await engine.insert('sys_user', { name: 'Ghost', email: SEED_EMAIL }, SYSTEM); + + const { logs } = await runDevAdminSeed(engine, manager); + + expect(manager.isOperatorProvisioning(SEED_EMAIL)).toBe(false); + expect(await readRows(engine, 'sys_account')).toEqual([]); + // The failure is reported rather than swallowed. + expect(logs.some((l) => l.level === 'warn')).toBe(true); + }); + + it('⑥ the gate predicate itself: people are not logins, and an unreadable store is its own verdict', async () => { + const engine = await bootEngine(); + await seedPeople(engine, 13); + + expect(await decideDevAdminSeedGate(engine as never, SEED_EMAIL)).toEqual({ act: true }); + expect(await decideDevAdminSeedGate(undefined, SEED_EMAIL)).toEqual({ + act: false, + reason: 'unanswerable', + }); + const throwing = { + find: () => Promise.reject(new Error('store unavailable')), + }; + expect(await decideDevAdminSeedGate(throwing, SEED_EMAIL)).toEqual({ + act: false, + reason: 'unanswerable', + }); + }); + + it('⑦ bootstrap-status agrees with the admission gate — a legacy usr_system row is NOT an owner', async () => { + const engine = await bootEngine(); + // The service account an older runtime provisioned. It is not a human, and + // the admission gate still stands ready to admit the first one. + await engine.insert( + 'sys_user', + { id: 'usr_system', email: 'system@localhost', name: 'System', role: 'system' }, + SYSTEM, + ); + const manager = makeManager(engine); + const app = mountBootstrapStatus(manager); + + // The expression the handler used to evaluate — non-vacuity for this pin. + expect(await engine.count('sys_user', {} as never, SYSTEM)).toBe(1); + + expect(await bootstrapStatus(app)).toEqual({ hasOwner: false }); + expect(await manager.hasBootstrapWindow()).toBe(true); + }); + + it('⑧ bootstrap-status: a human owner closes it, and a seeded dev admin IS that owner', async () => { + const engine = await bootEngine(); + const app0 = mountBootstrapStatus(makeManager(engine)); + expect(await bootstrapStatus(app0), 'an empty install offers first-run setup').toEqual({ + hasOwner: false, + }); + + const { manager } = await runDevAdminSeed(engine); + + expect(await bootstrapStatus(mountBootstrapStatus(manager))).toEqual({ hasOwner: true }); + }); +}); diff --git a/packages/plugins/plugin-auth/src/dev-admin-seed-gate.ts b/packages/plugins/plugin-auth/src/dev-admin-seed-gate.ts new file mode 100644 index 0000000000..58f39341ca --- /dev/null +++ b/packages/plugins/plugin-auth/src/dev-admin-seed-gate.ts @@ -0,0 +1,162 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#14157] THE dev-admin seed's precondition — "does a LOGIN already exist?" + * + * ## The defect this module exists to close + * + * `objectstack dev` declares, in its own `--help`, that it seeds *a known, + * loginable dev admin … never overwrites an existing account*. The gate that + * implemented it asked a different question: **does any human `sys_user` row + * exist?** Those two are the same question only while every user row carries a + * credential. + * + * They come apart the moment an app declares people in `defineStack({ data })`. + * A seeded person is a **directory row with no account** — it is not a login, + * and treating it as one is the defect. The declarative seed is awaited inside + * `AppPlugin.start()`, so it has always landed before the seed's own + * `kernel:ready` hook runs: the database is non-zero-user *before* the check, + * the admin is never minted, and because the row survives, it is never minted + * on any later boot either. The deployment ends up with **no loginable account + * at all** — measured end to end on a 13-person demo seed: 13 `sys_user` rows, + * **zero** `sys_account` rows, `POST /auth/sign-in/email` → 401. + * + * So the predicate moves from the directory to the credential store, which is + * where the answer actually lives. Two reads, because the card's two suggested + * spellings each guard a case the other does not: + * + * 1. **Is the configured seed address already claimed?** Any account of any + * provider on that address means the seed must not touch it — the + * never-overwrite half of the declared contract, held whether that account + * is a local password or a federated identity. (This is the question + * `maybeReportExistingSeedAdmin` already asks one function later; it is + * asked *here* too so the gate and the report agree.) + * 2. **Does a local password login exist anywhere?** That is what the seed + * provides, so that is what makes providing it unnecessary. `provider_id: + * 'credential'` is the card's own prescription and triage's ruled fix + * direction ("gate on a credential-bearing account, not on user rows"). + * + * ## Fail posture: unanswerable ⇒ do not act, loudly + * + * A probe that cannot be answered is NOT "no login" — reading it that way is + * how a gate mints a known-credential account into an environment it could not + * read. `unanswerable` is therefore its own verdict and the caller reports it, + * rather than folding into either decision. In practice it is close to + * unreachable: `sys_account` is registered by this very plugin, so a + * composition that has an auth plugin has the table this probe reads. + * + * ## Deliberate NON-consequence: this widens no public door + * + * The gate decides only whether the deployment's own boot command provisions + * its admin. The *admission* of that provisioning call is a separate question + * with a separate answer (`AuthManager.stageOperatorProvisioning`), because the + * audience gate's bootstrap bypass — "zero HUMAN users" — is a public + * self-registration carve-out and must keep counting humans. Measured: with 13 + * seeded people and zero accounts, the seed's own `api.signUpEmail` call is + * refused `SELF_REGISTRATION_CLOSED` under the default `invite_only` posture. + * A fix that moved only this gate would have produced a seed that decides to + * run and a gate that then refuses it — the exact drift `isHumanUserRow`'s doc + * warns about. + */ + +import { SystemObjectName } from '@objectstack/spec/system'; + +/** The bounded reads this probe performs — every data engine satisfies them. */ +export interface DevAdminSeedProbeEngine { + find(object: string, query: Record, options?: unknown): Promise; +} + +/** + * Why the seed is not acting on this boot — or `act: true` when it should. + * + * The reasons are distinct because the caller says different things about + * them: a claimed address and an existing local login are both normal, + * expected outcomes that re-arm the credential hint, while `unanswerable` is a + * degradation the operator has to be told about. + */ +export type DevAdminSeedGateVerdict = + | { act: true } + | { + act: false; + /** + * `seed-address-claimed` — an account already holds the configured seed + * address (never overwrite it). + * `local-login-exists` — some other local password login already exists, + * so the environment is not login-less and the seed has nothing to add. + * `unanswerable` — the credential store could not be read; the seed + * declines rather than guessing. + */ + reason: 'seed-address-claimed' | 'local-login-exists' | 'unanswerable'; + }; + +const SYSTEM_READ = { context: { isSystem: true } } as const; + +/** better-auth's local password provider — the one the dev seed provisions. */ +export const CREDENTIAL_PROVIDER_ID = 'credential'; + +function 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[]) : []; +} + +/** + * Decide whether the dev-admin seed should provision on this boot. + * + * Never throws: an unanswerable read is reported as `unanswerable`, never + * silently folded into "no login exists". + * + * @param engine the data engine, read through the system context + * @param seedEmail the address {@link devSeedAdminEmail} resolved + */ +export async function decideDevAdminSeedGate( + engine: DevAdminSeedProbeEngine | undefined, + seedEmail: string, +): Promise { + if (!engine || typeof engine.find !== 'function') return { act: false, reason: 'unanswerable' }; + try { + // (1) Is the configured address already claimed by an account? + // + // Both spellings, the same two-spelling read the walled-owner probe + // performs: better-auth lowercases on `createUser`, but a row inserted by + // some other path (an app seed, an import) carries whatever it was given, + // and `OS_SEED_ADMIN_EMAIL` may itself be mixed case. + const spellings = [...new Set([seedEmail, seedEmail.trim().toLowerCase()])]; + const seedUserIds = new Set(); + for (const spelling of spellings) { + for (const row of asRows( + await engine.find( + SystemObjectName.USER, + { where: { email: spelling }, limit: 5 }, + SYSTEM_READ, + ), + )) { + if (row?.id != null) seedUserIds.add(row.id); + } + } + for (const userId of seedUserIds) { + const accounts = asRows( + await engine.find( + SystemObjectName.ACCOUNT, + { where: { user_id: userId }, limit: 1 }, + SYSTEM_READ, + ), + ); + if (accounts.length > 0) return { act: false, reason: 'seed-address-claimed' }; + } + + // (2) Does any local password login exist at all? + const credentials = asRows( + await engine.find( + SystemObjectName.ACCOUNT, + { where: { provider_id: CREDENTIAL_PROVIDER_ID }, limit: 1 }, + SYSTEM_READ, + ), + ); + if (credentials.length > 0) return { act: false, reason: 'local-login-exists' }; + + return { act: true }; + } catch { + return { act: false, reason: 'unanswerable' }; + } +} From c89600f4ade303c144e2733a84a07c0bc2151311 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 02:26:48 +0000 Subject: [PATCH 2/4] wip(plugin-auth): changeset + shrink the route-envelope exemption to 2 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --- .changeset/dev-admin-seed-credential-gate.md | 36 ++++++++++++++++++++ scripts/check-route-envelope.mjs | 22 ++++++++---- 2 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 .changeset/dev-admin-seed-credential-gate.md diff --git a/.changeset/dev-admin-seed-credential-gate.md b/.changeset/dev-admin-seed-credential-gate.md new file mode 100644 index 0000000000..f5812a485a --- /dev/null +++ b/.changeset/dev-admin-seed-credential-gate.md @@ -0,0 +1,36 @@ +--- +'@objectstack/plugin-auth': patch +'@objectstack/cli': patch +--- + +Fix: `objectstack dev` now seeds its dev admin on a database that has PEOPLE but no LOGIN + +An app that declares `sys_user` rows in `defineStack({ data })` used to lose the +`objectstack dev` login permanently. The declarative seed is awaited inside +`AppPlugin.start()`, so it always landed before the dev-admin seed's own +`kernel:ready` hook; the seed's gate asked "does any human `sys_user` row +exist?" and skipped — on that boot and on every later one, because the rows +survive. The deployment ended up with no loginable account at all: people rows +present, `sys_account` empty, `POST /auth/sign-in/email` returning 401. + +A seeded person is a directory row with no credential. It is not a login, and +the gate now says so: the seed acts while no account holds the configured seed +address and no local password login (`sys_account.provider_id = 'credential'`) +exists anywhere. "Never overwrites an existing account" is unchanged and now +covers federated accounts on that address too. A credential store that cannot be +read is its own verdict — the seed declines and says so, rather than minting a +known-credential admin into an environment it could not see. + +The seed's own provisioning call is now admitted as what it is — the +deployment's own boot command provisioning its admin — instead of riding on the +audience gate's zero-human bootstrap bypass, which the app's people seed also +answers "populated". The public self-registration door is unchanged: nothing +outside the process can stage that declaration, it names one address, and it is +cleared as soon as the call returns. + +`GET /api/v1/auth/bootstrap-status` now answers with the same bootstrap-window +question the admission gate asks, instead of counting `sys_user` rows with no +filter. On a database still carrying the legacy `usr_system` service row it used +to report `hasOwner: true` while the admission gate and plugin-security's +first-user detection both stood ready to admit and promote the first human — so +the console withheld a first-run setup flow the platform would have accepted. diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index 4233e7e55e..baa394a30b 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -752,17 +752,25 @@ const PLUGIN_ROUTE_MODULES = { 'pre-auth bootstrap, ruled outside BaseResponseSchema by design (2026-08-17, #9389 option B): the shell reads /auth/me/permissions, /auth/me/localization and /me/apps to decide what to render, and each answers an unauthenticated caller a bare `{ authenticated: false }` / `{ apps: [] }` instead of refusing — our own shells branch on that top-level field with no unwrap step in between', }, - // THREE bodies, all `{ hasOwner: … }` from `/bootstrap-status` (1681/1684/ - // 1687), whose own comment states the boundary: "Public, unauthenticated; only - // returns a boolean so it can be polled before the user has any credentials." + // TWO bodies, both `{ hasOwner: … }` from `/bootstrap-status`, whose own + // comment states the boundary: "Public, unauthenticated; only returns a + // boolean so it can be polled before the user has any credentials." + // + // [#14157] It was THREE until the handler stopped counting `sys_user` rows. + // The third body was its no-engine early return, which existed only because + // the handler resolved the data engine itself; asking + // `AuthManager.hasBootstrapWindow()` — the same question the admission gate + // answers, which reports "no window" without an engine — folds that branch + // into the ordinary answer. Shrinking a ruled exemption needs nobody's leave; + // it is the widening direction that is MAINTAINER-ONLY. // // The count is also what keeps this file's OTHER 46 bodies audited — the - // conformant `{ success: true, data: config }` of `/auth/public-config` at - // 1663 among them — instead of one waiver retiring the lot. + // conformant `{ success: true, data: config }` of `/auth/public-config` + // among them — instead of one waiver retiring the lot. 'packages/plugins/plugin-auth/src/auth-plugin.ts': { - unenveloped: 3, + unenveloped: 2, exempt: - 'pre-auth bootstrap, ruled outside BaseResponseSchema by design (2026-08-17, #9389 option B): /bootstrap-status is polled by the Account SPA to choose between /login and first-run /setup, by a caller that has no credential to authenticate with yet. The rest of this file (~46 bodies) is better-auth\'s own wire format, relayed rather than built, and stays invisible to these counters by design', + 'pre-auth bootstrap, ruled outside BaseResponseSchema by design (2026-08-17, #9389 option B): /bootstrap-status is polled by the Account SPA to choose between /login and first-run /setup, by a caller that has no credential to authenticate with yet. What is left is the ordinary answer and the catch-all fallback; the third body — a no-engine early return — went away with #14157 when the handler stopped resolving the engine itself. The rest of this file (~46 bodies) is better-auth\'s own wire format, relayed rather than built, and stays invisible to these counters by design', }, // ── Ruled vendor wire format (2026-08-21, #10554) ──────────────────── From 04fcb96ad0b3d9a79cdda61103cf279a15e20dc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 03:02:44 +0000 Subject: [PATCH 3/4] =?UTF-8?q?chore(changeset):=20plugin-auth=20widens=20?= =?UTF-8?q?AuthManager's=20public=20surface=20=E2=80=94=20minor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --- .changeset/dev-admin-seed-credential-gate.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/dev-admin-seed-credential-gate.md b/.changeset/dev-admin-seed-credential-gate.md index f5812a485a..dfd9a63b3f 100644 --- a/.changeset/dev-admin-seed-credential-gate.md +++ b/.changeset/dev-admin-seed-credential-gate.md @@ -1,5 +1,5 @@ --- -'@objectstack/plugin-auth': patch +'@objectstack/plugin-auth': minor '@objectstack/cli': patch --- From 31fd3968cf0c0aadc5f75575bbbb411541b8bf53 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 03:26:51 +0000 Subject: [PATCH 4/4] docs(permissions): shift the system-context census anchor with auth-plugin.ts (#14157) Co-Authored-By: Claude Fable 5.1 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 7fe8c239f8..76bb02d66c 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:1296` | +| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1301` | | 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` |