diff --git a/.changeset/walled-owner-operator-verified.md b/.changeset/walled-owner-operator-verified.md new file mode 100644 index 0000000000..1ed3dff6fa --- /dev/null +++ b/.changeset/walled-owner-operator-verified.md @@ -0,0 +1,49 @@ +--- +"@objectstack/plugin-auth": minor +"@objectstack/types": minor +"@objectstack/plugin-security": patch +--- + +feat(auth): walled deployment's declared owner is email-verified at operator-provisioned creation (#12751) + +On a **walled** deployment (`OS_TENANCY_POSTURE` in the wall-enforcing +family), the account whose email equals the declared platform owner +(`OS_PLATFORM_OWNER_EMAIL`) is stamped `emailVerified` **at creation** when +it comes into existence through an **operator provisioning path** — extending +the #11343 dev-boot seeded-admin precedent to production walled boots +(maintainer ruling 2026-08-28, cloud#1677: 「运营方创建即视为已验证」; the +trust anchor is the operator's env-var declaration plus the +operator-executed creation, not a mailbox round-trip; SMTP stays required +only for inviting others). + +**Which creation paths qualify** (the [#11739] audience taxonomy, not a +second classification): + +- the **bootstrap carve-out** — the very first account on a fresh install + (zero human users), the one self-serve creation a walled boot admits; +- **admin create-user / bulk import** (`method: 'admin'`) — an act only an + authenticated admin session can perform; +- **SCIM** (`method: 'scim'`) — provisioning executed by the + operator-registered directory. + +**Never**: non-bootstrap self-registration (including an +invitation-admitted registration typing the owner address), provider-class +JIT (the IdP asserts its own `emailVerified` at insert), any non-owner +address, any unwalled posture, and a later email **update** to the owner +address (the stamp is staged at the admission gate and consumed once by the +`user.create` before-hook — a seam an update cannot traverse). Dev-boot +behaviour (#11343) is unchanged. + +The `WALLED_OWNER_NO_VERIFICATION_PATH` boot warning now probes the owner +account's state: a fresh walled boot with no transport and no federated +sign-in is **silent** (the operator's own first-account creation arrives +verified — the case this closes), while an owner account that already +exists **unverified**, a populated store whose bootstrap window is spent, +and an unanswerable probe keep warning. A settled deployment whose owner is +verified stops re-warning on every boot. + +`@objectstack/types` gains `isEmailVerifiedUserRow` — the [#11343] +fail-closed verified-representation allow-list, moved from +`plugin-security`'s private copy so the elevation gate and the boot +diagnostic read ONE resolution (`plugin-security` now consumes it; no +behaviour change there). diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 77821cc9bc..af0892f639 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -20,6 +20,7 @@ import { AUDIENCE_CONFIG_ERROR, type ResolvedAudience, } from './audience-posture.js'; +import { shouldStampOwnerVerifiedAtCreation } from './walled-owner-operator-stamp.js'; import type { IDataEngine } from '@objectstack/core'; // [#10348] The ONE id-shaped platform-admin predicate (ADR-0068 D2). // `auth-manager` used to re-derive that standing itself, in two spellings @@ -3509,6 +3510,20 @@ export class AuthManager { private static readonly SELF_REG_GRANT_STAGE_TTL_MS = 10 * 60 * 1000; + /** + * [#12751] Owner-verified stamps staged by the admission gate for the + * composed `user.create.before` hook — same shape and lifetime discipline + * as {@link pendingSelfRegistrationGrants} (keyed by lowercased email; + * better-auth lowercases the address on `createUser`, and an in-flight + * duplicate cannot create twice). One entry only ever exists for the + * declared platform owner's address on a walled deployment; TTL pruning + * keeps an admission whose creation never completed from leaking into an + * unrelated later creation of the same address. + */ + private pendingOwnerVerifiedStamps = new Map(); + + private static readonly OWNER_STAMP_STAGE_TTL_MS = 10 * 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 @@ -3614,6 +3629,23 @@ export class AuthManager { } return { error: verdict.code, errorDescription: verdict.message }; } + // [#12751] Walled deployments: an ADMITTED creation of the declared + // platform owner through an operator provisioning path (operator class, + // or the bootstrap carve-out) is stamped email-verified at creation — + // maintainer ruling 2026-08-28, 「运营方创建即视为已验证」. The decision + // (and the per-path argument) lives in `walled-owner-operator-stamp.ts`; + // this seam only STAGES it, because the admission gate is the one place + // that holds the vendor's own `source.method` signal AND the bootstrap + // probe. Consumed once by the composed `user.create.before` hook, so the + // row is BORN verified; an email UPDATE can never traverse that seam, + // which is what keeps a later change-to-owner-address from inheriting + // the stamp. + if ( + email && + shouldStampOwnerVerifiedAtCreation({ email, creationClass, isBootstrap }) + ) { + this.stageOwnerVerifiedStamp(email); + } if (verdict.grantPermissionSet) { const setName = audience.selfRegistrationPermissionSet; if (!setName) { @@ -3818,6 +3850,32 @@ export class AuthManager { } } + /** [#12751] Stage the owner-verified stamp for the address being created. */ + private stageOwnerVerifiedStamp(email: string): void { + this.prunePendingOwnerVerifiedStamps(); + this.pendingOwnerVerifiedStamps.set(email.trim().toLowerCase(), { stagedAtMs: Date.now() }); + } + + /** + * [#12751] Consume the staged stamp for this address — one shot: the entry + * is deleted on read, so exactly one creation can be born verified per + * admission, and nothing survives for any later write to inherit. + */ + private takeOwnerVerifiedStamp(email: string): boolean { + this.prunePendingOwnerVerifiedStamps(); + const key = email.trim().toLowerCase(); + if (!this.pendingOwnerVerifiedStamps.has(key)) return false; + this.pendingOwnerVerifiedStamps.delete(key); + return true; + } + + private prunePendingOwnerVerifiedStamps(): void { + const cutoff = Date.now() - AuthManager.OWNER_STAMP_STAGE_TTL_MS; + for (const [key, value] of this.pendingOwnerVerifiedStamps) { + if (value.stagedAtMs < cutoff) this.pendingOwnerVerifiedStamps.delete(key); + } + } + private stageSelfRegistrationGrant(email: string, setName: string): void { this.prunePendingSelfRegistrationGrants(); this.pendingSelfRegistrationGrants.set(email.trim().toLowerCase(), { @@ -5625,6 +5683,35 @@ export class AuthManager { await membershipReconciler(user); }; + // [#12751] Walled owner-verified stamp, the CONSUMING half: the admission + // gate staged the decision (see `validateAudienceAdmission` and + // `walled-owner-operator-stamp.ts`); this before-hook lands it, so the + // declared owner's operator-provisioned row is BORN `emailVerified: true` + // — the same at-creation shape as a trusted-SSO insert, and the creation + // write then replays `bootstrapPlatformAdmin` (`shouldReplayBootstrapFor`, + // `create` arm), which elevates it with no further verification step. + // `user.create.before` is a seam only a CREATION traverses, so a later + // email UPDATE to the owner address structurally cannot inherit the + // stamp. Host hook chains FIRST and keeps its result shape, exactly as + // `sessionBefore` above does; a host `false` (refuse the creation) is + // honoured before the stamp is even consumed. + const hostUserBefore = (host as any)?.user?.create?.before; + const userBefore = async (user: any, ctx: any) => { + let draft = user; + if (hostUserBefore) { + const hostResult = await hostUserBefore(user, ctx); + if (hostResult === false) return false; + if (hostResult && typeof hostResult === 'object' && 'data' in hostResult) { + draft = { ...draft, ...(hostResult as any).data }; + } + } + const email = typeof draft?.email === 'string' ? draft.email : ''; + if (email && this.takeOwnerVerifiedStamp(email)) { + return { data: { ...draft, emailVerified: true } }; + } + return draft === user ? undefined : { data: draft }; + }; + return { ...(host ?? {}), account: { @@ -5638,6 +5725,7 @@ export class AuthManager { ...((host as any)?.user ?? {}), create: { ...((host as any)?.user?.create ?? {}), + before: userBefore, after: userAfter, }, }, diff --git a/packages/plugins/plugin-auth/src/auth-plugin-walled-owner-verification-path.test.ts b/packages/plugins/plugin-auth/src/auth-plugin-walled-owner-verification-path.test.ts index 790a86c416..b55d76a147 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin-walled-owner-verification-path.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin-walled-owner-verification-path.test.ts @@ -15,6 +15,14 @@ * one does, so each neighbouring shape — a transport wired, a federated * sign-in wired, an unwalled posture, an undeclared owner, and the dev/harness * boot that verifies its own seeded owner — is pinned SILENT. + * + * [#12751] (maintainer ruling 2026-08-28, 「运营方创建即视为已验证」): the + * operator-provisioning stamp is itself a verification path, so the firing + * now follows the OWNER ACCOUNT STATE the caller probes — a fresh walled + * boot with nothing wired is SILENT (its owner's first-account creation + * arrives verified), while an owner account already existing unverified, a + * populated store with no owner account, and an unanswerable probe keep + * warning. The `#12751` describe below is that two-sided contract's pin. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -23,14 +31,23 @@ import { WALLED_OWNER_NO_VERIFICATION_PATH, resolveWalledOwnerVerificationPathWarning, warnIfWalledOwnerCannotVerify, + type WalledOwnerAccountState, } from './walled-owner-verification-path'; import type { PluginContext } from '@objectstack/core'; const OWNER = 'operator@corp.example'; const DEV_SEED_ADMIN = 'admin@objectos.ai'; -/** No transport, no federated sign-in — the shape the ruling is about. */ -const NOTHING_WIRED = { hasEmailTransport: false, hasFederatedSignIn: false } as const; +/** + * No transport, no federated sign-in, with the caller-resolved owner account + * state. [#12751] The default state here is `owner-unverified` — the shape + * that stays a dead end after the operator-provisioning stamp — so every + * pre-existing "the dead-end shape warns" pin below keeps measuring a real + * dead end rather than the fresh boot the stamp now covers. + */ +const nothingWired = (ownerAccountState: WalledOwnerAccountState = 'owner-unverified') => + ({ hasEmailTransport: false, hasFederatedSignIn: false, ownerAccountState }) as const; +const NOTHING_WIRED = nothingWired(); const ENV_KEYS = [ 'OS_TENANCY_POSTURE', @@ -122,10 +139,12 @@ describe('#11640 — the dead-end shape warns, by name and with the remedy', () describe('#11640 — controls: every neighbouring shape stays SILENT', () => { it('an email transport is wired ⇒ the verification link can be delivered ⇒ no warning', () => { walledWithDeclaredOwner(); + // Even against the worst account state: the transport IS the remedy. expect( resolveWalledOwnerVerificationPathWarning({ hasEmailTransport: true, hasFederatedSignIn: false, + ownerAccountState: 'owner-unverified', }), ).toBeNull(); }); @@ -136,6 +155,7 @@ describe('#11640 — controls: every neighbouring shape stays SILENT', () => { resolveWalledOwnerVerificationPathWarning({ hasEmailTransport: false, hasFederatedSignIn: true, + ownerAccountState: 'owner-unverified', }), ).toBeNull(); }); @@ -156,30 +176,82 @@ describe('#11640 — controls: every neighbouring shape stays SILENT', () => { it('a dev/harness boot that seeds THIS owner verifies it at startup ⇒ no warning', () => { // The dev-admin seed provisions the declared owner and stamps it // `email_verified` (#11343), which is a verification path even with no - // mailbox anywhere — the verify harness boots exactly this shape. + // mailbox anywhere — the verify harness boots exactly this shape. The + // seed acts on an empty store, and the harness boots that cannot probe + // one hand in 'unknown' — both stay silent. process.env.NODE_ENV = 'development'; walledWithDeclaredOwner('isolated', DEV_SEED_ADMIN); - expect(resolveWalledOwnerVerificationPathWarning(NOTHING_WIRED)).toBeNull(); + expect(resolveWalledOwnerVerificationPathWarning(nothingWired('no-human-users'))).toBeNull(); + expect(resolveWalledOwnerVerificationPathWarning(nothingWired('unknown'))).toBeNull(); // …and it follows the seed's own address knob, not a hard-coded default. process.env.OS_SEED_ADMIN_EMAIL = 'seeded-owner@corp.example'; process.env.OS_PLATFORM_OWNER_EMAIL = 'seeded-owner@corp.example'; - expect(resolveWalledOwnerVerificationPathWarning(NOTHING_WIRED)).toBeNull(); + expect(resolveWalledOwnerVerificationPathWarning(nothingWired('no-human-users'))).toBeNull(); }); it('…but a dev boot whose declared owner is NOT the seeded one is a real dead end', () => { process.env.NODE_ENV = 'development'; walledWithDeclaredOwner('isolated', OWNER); // seed provisions admin@objectos.ai - expect(resolveWalledOwnerVerificationPathWarning(NOTHING_WIRED)).toContain( - WALLED_OWNER_NO_VERIFICATION_PATH, - ); + const msg = resolveWalledOwnerVerificationPathWarning(nothingWired('no-human-users')); + expect(msg).toContain(WALLED_OWNER_NO_VERIFICATION_PATH); + // [#12751] …and the message says WHY the first-account stamp cannot help: + // the armed seed will spend the bootstrap carve-out on its own address. + expect(msg).toContain(DEV_SEED_ADMIN); }); - it('…and a dev boot with the seed switched OFF gets no free pass either', () => { + it('[#12751] …and the seed cannot rescue a store it will never touch — a populated dev boot still warns', () => { + // The seed acts only on an EMPTY store. An owner account that already + // exists unverified is past its reach, so even the address-matched dev + // shape is a real dead end there. process.env.NODE_ENV = 'development'; - process.env.OS_SEED_ADMIN = '0'; walledWithDeclaredOwner('isolated', DEV_SEED_ADMIN); - expect(resolveWalledOwnerVerificationPathWarning(NOTHING_WIRED)).toContain( + expect(resolveWalledOwnerVerificationPathWarning(nothingWired('owner-unverified'))).toContain( + WALLED_OWNER_NO_VERIFICATION_PATH, + ); + }); +}); + +// --------------------------------------------------------------------------- +// [#12751] 「运营方创建即视为已验证」 (maintainer, 2026-08-28): the operator +// provisioning stamp is itself a verification path, so the warning's firing +// now follows the OWNER ACCOUNT STATE — quiet where the stamp (or a finished +// verification) covers the deployment, loud where the store is past the +// stamp's reach. +// --------------------------------------------------------------------------- + +describe('#12751 — the warning follows the owner account state', () => { + it('THE CASE THIS CARD CLOSES: a fresh production walled boot with nothing wired stays SILENT — the operator first-account creation arrives verified', () => { + walledWithDeclaredOwner(); + // NODE_ENV is production-shaped here (the beforeEach cleared it), so the + // dev seed is NOT armed — pre-#12751 this exact shape warned on every + // fresh walled EE deployment following the shipped .env.example. + expect(resolveWalledOwnerVerificationPathWarning(nothingWired('no-human-users'))).toBeNull(); + }); + + it('an owner account that exists VERIFIED needs nothing — silent (also on every later boot of a settled deployment)', () => { + walledWithDeclaredOwner(); + expect(resolveWalledOwnerVerificationPathWarning(nothingWired('owner-verified'))).toBeNull(); + }); + + it('an owner account that exists UNVERIFIED is the dead end — warns, and names the situation', () => { + walledWithDeclaredOwner(); + const msg = resolveWalledOwnerVerificationPathWarning(nothingWired('owner-unverified')); + expect(msg).toContain(WALLED_OWNER_NO_VERIFICATION_PATH); + expect(msg).toContain('ALREADY EXISTS'); + expect(msg).toContain('walled_owner_not_verified'); + }); + + it('a populated store with NO owner account warns — the bootstrap window is spent and an invitee arrives unverified', () => { + walledWithDeclaredOwner(); + const msg = resolveWalledOwnerVerificationPathWarning(nothingWired('owner-absent')); + expect(msg).toContain(WALLED_OWNER_NO_VERIFICATION_PATH); + expect(msg).toContain('UNVERIFIED'); + }); + + it('an unanswerable probe warns — noisy over silent about a real dead end (the pre-#12751 posture)', () => { + walledWithDeclaredOwner(); + expect(resolveWalledOwnerVerificationPathWarning(nothingWired('unknown'))).toContain( WALLED_OWNER_NO_VERIFICATION_PATH, ); }); @@ -202,7 +274,10 @@ describe('#11640 — the emitter logs once, on the channel `serve` replays', () walledWithDeclaredOwner(); const logger = { warn: vi.fn(), error: vi.fn(), info: vi.fn() }; expect( - warnIfWalledOwnerCannotVerify({ hasEmailTransport: true, hasFederatedSignIn: false }, logger), + warnIfWalledOwnerCannotVerify( + { hasEmailTransport: true, hasFederatedSignIn: false, ownerAccountState: 'owner-unverified' }, + logger, + ), ).toBeNull(); expect(logger.warn).not.toHaveBeenCalled(); }); diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 8363440517..f3fdcd5822 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -66,7 +66,9 @@ import { scheduleLegacySsoSecretMigration } from './sso-client-secret.js'; import { devSeedAdminEmail, isDevAdminSeedArmed, + probeWalledOwnerAccountState, warnIfWalledOwnerCannotVerify, + type WalledOwnerAccountState, } from './walled-owner-verification-path.js'; import { judgePlatformAdmin, isPlatformAdminUser, type PlatformAdminActor } from './platform-admin-gate.js'; import { @@ -932,12 +934,29 @@ export class AuthPlugin implements Plugin { // this hook's answer independent of hook registration order. let pub: { socialProviders?: unknown[]; features?: { sso?: boolean } } | undefined; try { pub = this.authManager?.getPublicConfig(); } catch { pub = undefined; } + const hasEmailTransport = !!emailSvc || !!this.authManager?.hasEmailTransport(); + const hasFederatedSignIn = + (pub?.socialProviders?.length ?? 0) > 0 || pub?.features?.sso === true; + // [#12751] The third wiring fact: what the store says about the declared + // owner's account. Probed only when the answer can matter (walled + + // owner declared + neither transport nor federated sign-in wired), so + // every other boot pays nothing. This hook runs BEFORE the dev-seed + // 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 ownerAccountState: WalledOwnerAccountState = 'unknown'; + if ( + !hasEmailTransport && + !hasFederatedSignIn && + postureEnforcesWall(resolveTenancyPosture()) && + resolvePlatformOwnerEmail() + ) { + let ql: IDataEngine | undefined; + try { ql = ctx.getService('objectql'); } catch { ql = undefined; } + ownerAccountState = await probeWalledOwnerAccountState(ql); + } warnIfWalledOwnerCannotVerify( - { - hasEmailTransport: !!emailSvc || !!this.authManager?.hasEmailTransport(), - hasFederatedSignIn: - (pub?.socialProviders?.length ?? 0) > 0 || pub?.features?.sso === true, - }, + { hasEmailTransport, hasFederatedSignIn, ownerAccountState }, ctx.logger, ); }); diff --git a/packages/plugins/plugin-auth/src/walled-owner-operator-stamp.test.ts b/packages/plugins/plugin-auth/src/walled-owner-operator-stamp.test.ts new file mode 100644 index 0000000000..dbc3cab51c --- /dev/null +++ b/packages/plugins/plugin-auth/src/walled-owner-operator-stamp.test.ts @@ -0,0 +1,465 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#12751] The walled owner-verified stamp — maintainer ruling 2026-08-28 + * (cloud#1677, verbatim): 「运营方创建即视为已验证」. + * + * Three layers, matching the implementation's: + * + * 1. **The pure decision matrix** (`shouldStampOwnerVerifiedAtCreation`) — + * every bound of the contract as a direct call: walled family only, + * declared-owner match only (compared the way the elevation gate + * compares), operator-provisioned creation only (operator class, or the + * bootstrap carve-out; provider and non-bootstrap self-serve NEVER). + * 2. **The store probe** (`probeWalledOwnerAccountState`) over a REAL + * `ObjectQL` engine — the same backend the elevation gate reads, so the + * probe's answers are measured against real driver representations, not + * a fake's. + * 3. **The wiring, end to end** — real better-auth pipeline over the real + * engine (the `audience-bootstrap-seam` harness shape): the declared + * owner's operator-provisioned row is BORN `email_verified`, and every + * "never" cell of the matrix stays unverified through the same pipeline. + * The verified read-back uses the shared [#11343] allow-list + * (`isEmailVerifiedUserRow`) — the predicate the elevation gate itself + * refuses on — so a green here IS "the elevation gate would accept this + * row", without booting plugin-security. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { isEmailVerifiedUserRow } from '@objectstack/types'; +import { AuthManager } from './auth-manager.js'; +import { + isOperatorProvisionedCreation, + shouldStampOwnerVerifiedAtCreation, +} from './walled-owner-operator-stamp.js'; +import { probeWalledOwnerAccountState } from './walled-owner-verification-path.js'; +import { inviteForAudienceGate } from './audience-gate-test-support'; +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-12751'; +const PASSWORD = 'S3cure!Passw0rd-12751'; +const OWNER = 'operator@corp.example'; + +// ── env stubbing — the decision reads posture + owner from the environment ── + +const ENV_KEYS = [ + 'OS_TENANCY_POSTURE', + 'OS_MULTI_ORG_ENABLED', + 'OS_PLATFORM_OWNER_EMAIL', + 'OS_SEED_ADMIN', + 'OS_SEED_ADMIN_EMAIL', + '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]; + } +}); + +const walledWithOwner = (owner = OWNER, posture = 'isolated') => { + process.env.OS_TENANCY_POSTURE = posture; + process.env.OS_PLATFORM_OWNER_EMAIL = owner; +}; + +// ── the real-engine harness (the audience-bootstrap-seam shape) ───────────── + +const AUTH_OBJECTS = [ + SysUser, + SysSession, + SysAccount, + SysVerification, + SysOrganization, + SysMember, + SysInvitation, + SysTeam, + SysTeamMember, +]; + +const engines: ObjectQL[] = []; +afterEach(async () => { + 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; +} + +function makeManager(engine: ObjectQL, config: Record = {}): AuthManager { + return new AuthManager({ + secret: SECRET, + baseUrl: BASE, + dataEngine: engine as never, + ...config, + } as never); +} + +function post(manager: AuthManager, path: string, body: unknown, bearer?: string): Promise { + return manager.handleRequest( + new Request(`${AUTH}${path}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + origin: BASE, + ...(bearer ? { authorization: `Bearer ${bearer}` } : {}), + }, + body: JSON.stringify(body), + }), + ); +} + +async function signUp(manager: AuthManager, email: string): Promise { + return post(manager, '/sign-up/email', { email, password: PASSWORD, name: email.split('@')[0] }); +} + +async function userRow(engine: ObjectQL, email: string): Promise> { + const rows = (await engine.find( + 'sys_user', + { where: { email: email.toLowerCase() }, limit: 2 }, + { context: { isSystem: true } } as never, + )) as Record[]; + expect(rows, `expected exactly one sys_user row for ${email}`).toHaveLength(1); + return rows[0]; +} + +// ──────────────────────────────────────────────────────────────────────────── +// 1. The pure decision matrix. +// ──────────────────────────────────────────────────────────────────────────── + +describe('#12751 — shouldStampOwnerVerifiedAtCreation, the contract as a matrix', () => { + it('owner via OPERATOR class on a walled deployment ⇒ stamp', () => { + walledWithOwner(); + expect( + shouldStampOwnerVerifiedAtCreation({ email: OWNER, creationClass: 'operator', isBootstrap: false }), + ).toBe(true); + }); + + it('owner via the BOOTSTRAP carve-out (first self-serve account) on a walled deployment ⇒ stamp', () => { + walledWithOwner(); + expect( + shouldStampOwnerVerifiedAtCreation({ email: OWNER, creationClass: 'self-serve', isBootstrap: true }), + ).toBe(true); + }); + + it('owner via NON-bootstrap self-serve ⇒ NEVER — a self-registrant typing the owner address proves nothing', () => { + walledWithOwner(); + expect( + shouldStampOwnerVerifiedAtCreation({ email: OWNER, creationClass: 'self-serve', isBootstrap: false }), + ).toBe(false); + }); + + it('owner via PROVIDER class ⇒ NEVER — the IdP asserts its own emailVerified at insert', () => { + walledWithOwner(); + for (const isBootstrap of [true, false]) { + expect( + shouldStampOwnerVerifiedAtCreation({ email: OWNER, creationClass: 'provider', isBootstrap }), + ).toBe(false); + } + }); + + it('NON-owner via operator paths ⇒ never, on every class', () => { + walledWithOwner(); + expect( + shouldStampOwnerVerifiedAtCreation({ + email: 'stranger@corp.example', + creationClass: 'operator', + isBootstrap: false, + }), + ).toBe(false); + expect( + shouldStampOwnerVerifiedAtCreation({ + email: 'stranger@corp.example', + creationClass: 'self-serve', + isBootstrap: true, + }), + ).toBe(false); + }); + + it('UNWALLED postures ⇒ never — elevation there never demands a verified owner', () => { + process.env.OS_TENANCY_POSTURE = 'single'; + process.env.OS_PLATFORM_OWNER_EMAIL = OWNER; + expect( + shouldStampOwnerVerifiedAtCreation({ email: OWNER, creationClass: 'operator', isBootstrap: false }), + ).toBe(false); + expect( + shouldStampOwnerVerifiedAtCreation({ email: OWNER, creationClass: 'self-serve', isBootstrap: true }), + ).toBe(false); + }); + + it('the whole walled FAMILY is covered — `group` too', () => { + walledWithOwner(OWNER, 'group'); + expect( + shouldStampOwnerVerifiedAtCreation({ email: OWNER, creationClass: 'self-serve', isBootstrap: true }), + ).toBe(true); + }); + + it('owner UNDECLARED ⇒ never (that walled boot is refused elsewhere; off the boot path, no stamp)', () => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + expect( + shouldStampOwnerVerifiedAtCreation({ email: OWNER, creationClass: 'operator', isBootstrap: false }), + ).toBe(false); + }); + + it('the email comparison MIRRORS the elevation gate: trimmed, case-insensitive, both sides', () => { + // The env declaration arrives padded and cased however the operator typed + // it; `resolvePlatformOwnerEmail` trims, the comparison lowercases — the + // exact treatment `bootstrapPlatformAdmin` gives the same pair. + walledWithOwner(' Operator@CORP.example '); + expect( + shouldStampOwnerVerifiedAtCreation({ + email: 'operator@corp.example', + creationClass: 'self-serve', + isBootstrap: true, + }), + ).toBe(true); + expect( + shouldStampOwnerVerifiedAtCreation({ + email: ' OPERATOR@corp.example ', + creationClass: 'operator', + isBootstrap: false, + }), + ).toBe(true); + // …and a DIFFERENT address does not fold into a match. + expect( + shouldStampOwnerVerifiedAtCreation({ + email: 'operator2@corp.example', + creationClass: 'operator', + isBootstrap: false, + }), + ).toBe(false); + }); + + it('isOperatorProvisionedCreation names the qualifying paths and nothing else', () => { + expect(isOperatorProvisionedCreation('operator', false)).toBe(true); + expect(isOperatorProvisionedCreation('self-serve', true)).toBe(true); + expect(isOperatorProvisionedCreation('self-serve', false)).toBe(false); + expect(isOperatorProvisionedCreation('provider', true)).toBe(false); + expect(isOperatorProvisionedCreation('provider', false)).toBe(false); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 2. The store probe, over the real engine. +// ──────────────────────────────────────────────────────────────────────────── + +describe('#12751 — probeWalledOwnerAccountState over a real ObjectQL engine', () => { + it('an empty store is `no-human-users`', async () => { + walledWithOwner(); + const engine = await bootEngine(); + expect(await probeWalledOwnerAccountState(engine as never)).toBe('no-human-users'); + }); + + it('a store whose owner account exists unverified / verified answers each by the shared #11343 allow-list', async () => { + walledWithOwner(); + const engine = await bootEngine(); + const manager = makeManager(engine); + expect((await signUp(manager, 'bystander@corp.example')).status).toBe(200); + await inviteForAudienceGate(manager, OWNER); + // Break the stamp's own lane on purpose: an INVITED self-serve owner + // arrives unverified (pinned below), which is exactly the probe's + // `owner-unverified` shape. + expect((await signUp(manager, OWNER)).status).toBe(200); + expect(await probeWalledOwnerAccountState(engine as never)).toBe('owner-unverified'); + + // Verify it the way better-auth would (the sys_user UPDATE doorway) and + // the probe follows. + const row = await userRow(engine, OWNER); + await engine.update( + 'sys_user', + { id: row.id, email_verified: true } as never, + { context: { isSystem: true } } as never, + ); + expect(await probeWalledOwnerAccountState(engine as never)).toBe('owner-verified'); + }); + + it('a populated store with no owner account is `owner-absent`', async () => { + walledWithOwner(); + const engine = await bootEngine(); + const manager = makeManager(engine); + expect((await signUp(manager, 'bystander@corp.example')).status).toBe(200); + expect(await probeWalledOwnerAccountState(engine as never)).toBe('owner-absent'); + }); + + it('no engine / no declared owner ⇒ `unknown` — the loud fallback', async () => { + walledWithOwner(); + expect(await probeWalledOwnerAccountState(undefined)).toBe('unknown'); + delete process.env.OS_PLATFORM_OWNER_EMAIL; + const engine = await bootEngine(); + expect(await probeWalledOwnerAccountState(engine as never)).toBe('unknown'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 3. End to end: the real better-auth pipeline over the real engine. +// ──────────────────────────────────────────────────────────────────────────── + +describe('#12751 — the stamp lands through the REAL creation pipeline', () => { + it('THE CONSUMER CASE: on a walled deployment the declared owner’s bootstrap first account is BORN verified', async () => { + walledWithOwner(); + const engine = await bootEngine(); + const manager = makeManager(engine); + + const res = await signUp(manager, OWNER); + expect(res.status, `owner bootstrap sign-up refused: ${await res.clone().text()}`).toBe(200); + + const row = await userRow(engine, OWNER); + // Read back through the elevation gate's own predicate: a `true` here is + // "bootstrapPlatformAdmin would elevate this row", representation + // included. + expect( + isEmailVerifiedUserRow(row), + `owner row not verified at creation: email_verified=${JSON.stringify(row.email_verified)}`, + ).toBe(true); + }); + + it('…and the seed’s own server-side lane (api.signUpEmail) lands the same way — the walled dev boot keeps working', async () => { + // The dev-admin seed calls `api.signUpEmail` in-process and then applies + // its own #11343 stamp. With #12751 the row is already BORN verified on a + // walled boot (this lane), so the seed's later update is an idempotent + // no-op — same terminal state, no behaviour change. + process.env.NODE_ENV = 'development'; + walledWithOwner('admin@objectos.ai'); + const engine = await bootEngine(); + const manager = makeManager(engine); + const api = (await manager.getApi()) as unknown as { + signUpEmail(input: { body: Record }): Promise; + }; + await api.signUpEmail({ + body: { email: 'admin@objectos.ai', password: PASSWORD, name: 'Dev Admin' }, + }); + expect(isEmailVerifiedUserRow(await userRow(engine, 'admin@objectos.ai'))).toBe(true); + }); + + it('a NON-owner bootstrap first account is NOT stamped', async () => { + walledWithOwner(); + const engine = await bootEngine(); + const manager = makeManager(engine); + expect((await signUp(manager, 'bystander@corp.example')).status).toBe(200); + expect(isEmailVerifiedUserRow(await userRow(engine, 'bystander@corp.example'))).toBe(false); + }); + + it('on an UNWALLED posture the owner’s first account is NOT stamped — dev-boot/single behaviour unchanged', async () => { + process.env.OS_TENANCY_POSTURE = 'single'; + process.env.OS_PLATFORM_OWNER_EMAIL = OWNER; + const engine = await bootEngine(); + const manager = makeManager(engine); + expect((await signUp(manager, OWNER)).status).toBe(200); + expect(isEmailVerifiedUserRow(await userRow(engine, OWNER))).toBe(false); + }); + + it('an INVITED self-serve registration typing the owner address is NOT stamped — self-registration never qualifies', async () => { + walledWithOwner(); + const engine = await bootEngine(); + const manager = makeManager(engine); + expect((await signUp(manager, 'bystander@corp.example')).status).toBe(200); // spends bootstrap + await inviteForAudienceGate(manager, OWNER); + expect((await signUp(manager, OWNER)).status).toBe(200); // invitation carve-out admits it + expect(isEmailVerifiedUserRow(await userRow(engine, OWNER))).toBe(false); + }); + + it('a later email UPDATE to the owner address inherits NOTHING', async () => { + walledWithOwner(); + const engine = await bootEngine(); + const manager = makeManager(engine); + expect((await signUp(manager, 'bystander@corp.example')).status).toBe(200); + const before = await userRow(engine, 'bystander@corp.example'); + expect(isEmailVerifiedUserRow(before)).toBe(false); + + // The rename lane the contract names: the account MOVES ONTO the declared + // owner address after creation. The stamp is consumed at `user.create` + // only, so nothing fires here. + await engine.update( + 'sys_user', + { id: before.id, email: OWNER } as never, + { context: { isSystem: true } } as never, + ); + expect(isEmailVerifiedUserRow(await userRow(engine, OWNER))).toBe(false); + }); + + it('OPERATOR admin-create of the declared owner is BORN verified; of anyone else, NOT', async () => { + walledWithOwner(); + const engine = await bootEngine(); + const manager = makeManager(engine, { plugins: { admin: true } }); + + // Founder (non-owner) takes the bootstrap slot, then becomes a vendor + // admin (`role: 'admin'` — the better-auth admin plugin's own gate). + expect((await signUp(manager, 'founder@corp.example')).status).toBe(200); + const founder = await userRow(engine, 'founder@corp.example'); + await engine.update( + 'sys_user', + { id: founder.id, role: 'admin' } as never, + { context: { isSystem: true } } as never, + ); + const signIn = await post(manager, '/sign-in/email', { + email: 'founder@corp.example', + password: PASSWORD, + }); + expect(signIn.status, `founder sign-in failed: ${await signIn.clone().text()}`).toBe(200); + const bearer = signIn.headers.get('set-auth-token'); + expect(bearer, 'sign-in must mint a bearer or the admin legs prove nothing').toBeTruthy(); + + // The operator act: better-auth admin create-user, `{ method: 'admin' }` + // at the validateUserInfo seam — the operator class. + const createOwner = await post( + manager, + '/admin/create-user', + { email: OWNER, password: PASSWORD, name: 'Operator' }, + bearer!, + ); + expect(createOwner.status, `admin create-user refused: ${await createOwner.clone().text()}`).toBe(200); + expect(isEmailVerifiedUserRow(await userRow(engine, OWNER))).toBe(true); + + // Control: the same operator act for a NON-owner address stamps nothing. + const createOther = await post( + manager, + '/admin/create-user', + { email: 'colleague@corp.example', password: PASSWORD, name: 'Colleague' }, + bearer!, + ); + expect(createOther.status, `admin create-user refused: ${await createOther.clone().text()}`).toBe(200); + expect(isEmailVerifiedUserRow(await userRow(engine, 'colleague@corp.example'))).toBe(false); + }); +}); diff --git a/packages/plugins/plugin-auth/src/walled-owner-operator-stamp.ts b/packages/plugins/plugin-auth/src/walled-owner-operator-stamp.ts new file mode 100644 index 0000000000..497e8d21f5 --- /dev/null +++ b/packages/plugins/plugin-auth/src/walled-owner-operator-stamp.ts @@ -0,0 +1,136 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#12751] On a walled deployment, the declared platform owner is + * email-verified at OPERATOR-provisioned creation. + * + * Maintainer ruling 2026-08-28 (cloud#1677, verbatim): + * 「运营方创建即视为已验证」— the declared platform owner + * (`OS_PLATFORM_OWNER_EMAIL`), when its account comes into existence through + * the operator's own bootstrap/provisioning path on a walled deployment, is + * stamped email-verified at creation. The trust anchor is the operator's + * env-var declaration PLUS the operator-executed creation — not a mailbox + * round-trip; SMTP stays required only for inviting OTHERS. This extends the + * #11343 precedent (the dev-boot seeded admin, `auth-plugin.ts` + * `maybeSeedDevAdmin`: "provisioned by the deployment's own boot command with + * operator-known credentials — not an unknown self-registrant") to + * production walled boots, whose owner previously had NO in-product way to + * ever satisfy the verified-elevation invariant when no mail transport and + * no federated sign-in were wired (`WALLED_OWNER_NO_VERIFICATION_PATH`). + * Rejected alternatives, from the same ruling: mandating a mail transport + * out of the box, and a separate CLI stamp command. + * + * ## Which creation paths qualify as "operator-provisioned", and why + * + * The classification is [#11739]'s audience taxonomy + * (`classifyCreationMethod`) — deliberately NOT a second, parallel reading of + * the same question: + * + * - **`operator` class** (`admin` create-user / bulk import, `scim`): + * qualifies. An `admin` creation only exists inside an authenticated + * admin session — it IS the provisioning mechanism the closed postures + * point operators at. A `scim` creation is executed by the + * operator-registered directory: registering the IdP is the operator + * declaring "this directory is my audience" (audience-posture.ts), and + * the provisioning request is that declaration acting. + * - **`self-serve` class WITH the bootstrap carve-out** (`isBootstrap`: + * zero human users — the very first account on a fresh install): + * qualifies. On a walled deployment self-registration is closed; the one + * self-serve creation a fresh walled boot admits is the bootstrap + * carve-out, which exists precisely because the first account is presumed + * to be the operator standing the deployment up ("a fresh install must + * never lock its operator out"). The declared-owner match narrows that + * presumption further: the address only the operator's own environment + * declaration names. Residual exposure — a stranger who reaches the + * sign-up endpoint of a freshly booted walled deployment BEFORE the + * operator does, typing the operator's own declared address — is the + * same first-account trust the carve-out already extends, and the + * pre-#12751 outcome of that race was already operator intervention + * (address squatted unverified, elevation refused forever, deployment + * dead for its owner). The ruling accepts the env declaration + the + * creation act as the anchor. + * - **`self-serve` class WITHOUT the carve-out**: NEVER qualifies — a + * self-registrant typing the owner's address proves nothing (#11343's + * whole point), and that includes an invitation-admitted registration + * (the invitation carve-out admits the CREATION; it does not verify the + * mailbox). + * - **`provider` class** (enterprise SSO / operator-registered OIDC JIT): + * deliberately NOT stamped here. The IdP asserts its own + * `emailVerified` at insert (better-auth writes it straight off the + * provider profile), and overriding an authority that DECLINED to assert + * the address would manufacture verification nobody stands behind. A + * verified IdP claim already arrives verified without this module. + * + * ## The bounds (the contract, not suggestions) + * + * - ONLY under the walled posture family (`postureEnforcesWall` over the + * REQUESTED posture — the same input the elevation gate reads, for the + * same fail-stricter reason documented in `bootstrapPlatformAdmin`). + * - ONLY the account whose email equals the declared + * `OS_PLATFORM_OWNER_EMAIL`, compared the way the elevation gate compares + * (trimmed, case-insensitive — mirrored, not reinvented). + * - ONLY at CREATION, through the seam below. A later email UPDATE to the + * owner address inherits nothing: the decision is staged from the + * creation-time admission gate and consumed once by the `user.create` + * before-hook, a seam an update can never traverse. + * - Dev-boot behaviour (#11343's seed stamp) is unchanged: on a walled dev + * boot whose declared owner is the seeded address, this module stamps the + * same account the seed would have stamped a moment later — idempotent by + * construction. + * + * ## Wiring + * + * The DECISION lives here, pure over its inputs plus the two env + * resolutions. AuthManager stages it in `validateAudienceAdmission` (the one + * admission seam every creation path flows through, where the vendor's own + * `source.method` signal and the bootstrap probe already exist) and consumes + * it in the composed `user.create.before` database hook, so the row is BORN + * `emailVerified: true` — the same shape as a trusted-SSO insert. The + * elevation itself needs no new trigger: the creation write already replays + * `bootstrapPlatformAdmin` (`shouldReplayBootstrapFor`, `create` arm). + */ + +import { + resolvePlatformOwnerEmail, + resolveTenancyPosture, +} from '@objectstack/types'; +import { postureEnforcesWall } from '@objectstack/spec/security'; +import type { AudienceCreationClass } from './audience-posture.js'; + +/** + * Does this creation come into existence through an operator provisioning + * path? See the module doc for the per-path argument. + */ +export function isOperatorProvisionedCreation( + creationClass: AudienceCreationClass, + isBootstrap: boolean, +): boolean { + if (creationClass === 'operator') return true; + return creationClass === 'self-serve' && isBootstrap; +} + +/** + * The whole stamp decision: walled posture family + declared-owner email + * match + operator-provisioned creation. `false` for every other shape — + * including every shape on an unwalled deployment, where elevation never + * demands a verified owner and the stamp would be an unearned state change. + * + * The email comparison mirrors `bootstrapPlatformAdmin`'s owner match + * (candidate: `String(email).trim().toLowerCase()`; declared: already + * trimmed by `resolvePlatformOwnerEmail`, lowercased here) — the two MUST + * agree, or an account this module stamps is one the gate then fails to + * elevate. + */ +export function shouldStampOwnerVerifiedAtCreation(input: { + email: string | undefined; + creationClass: AudienceCreationClass; + isBootstrap: boolean; +}): boolean { + if (!isOperatorProvisionedCreation(input.creationClass, input.isBootstrap)) return false; + if (!postureEnforcesWall(resolveTenancyPosture())) return false; + const declared = resolvePlatformOwnerEmail(); + if (!declared) return false; + const candidate = typeof input.email === 'string' ? input.email.trim().toLowerCase() : ''; + if (!candidate) return false; + return candidate === declared.toLowerCase(); +} 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 a9516d3c67..2e249ced81 100644 --- a/packages/plugins/plugin-auth/src/walled-owner-verification-path.ts +++ b/packages/plugins/plugin-auth/src/walled-owner-verification-path.ts @@ -63,26 +63,41 @@ * boot-phase `info` is dropped; a boot-phase `warn` reaches the operator's * terminal. * - * ## Scope — deliberately half the problem + * ## [#12751] The operator-provisioning stamp changed which shapes dead-end * - * This covers the no-verification-path route only. Whether the *declared - * address itself* is already spoken for is a runtime state a boot check - * cannot see, and the ruling leaves it out on purpose; it stays an ungraded - * finding on the card's thread. + * Maintainer ruling 2026-08-28 (cloud#1677, 「运营方创建即视为已验证」): the + * declared owner's account, created through an OPERATOR provisioning path on + * a walled deployment — the bootstrap first account, admin create-user, SCIM + * — is stamped `email_verified` AT CREATION (`walled-owner-operator-stamp.ts`). + * So "no transport and no federated sign-in" is no longer, by itself, a dead + * end: a FRESH deployment's owner arrives verified through the operator's own + * first-account creation. What still dead-ends is an owner account that + * already exists UNVERIFIED (created outside the operator path — before this + * ruling, or through an invitation-admitted self-registration), or a + * populated store whose bootstrap window is spent with no owner account in + * it. Telling those apart requires the one runtime fact the original ruling + * deliberately left out — whether the declared address is already spoken for + * — so the caller now probes it ({@link probeWalledOwnerAccountState}) and + * this predicate reads the result. An unanswerable probe warns (the + * pre-#12751 behaviour for every shape): a diagnostic that cannot see may be + * noisy, never silent about a real dead end. * * ⚠️ **For whoever rewrites this surface** (the #11663 re-anchor legs L2 * #11970 / L4 #11974, both `pm:blocked` as of 2026-08-25): the decision lives * entirely in this module and the call site is one `kernel:ready` hook in - * `AuthPlugin.start()`. Move the call; the predicate and its message travel - * with the file. + * `AuthPlugin.start()`. Move the call; the predicate, the probe and the + * message travel with the file. */ import { PLATFORM_OWNER_EMAIL_ENV, + isEmailVerifiedUserRow, resolvePlatformOwnerEmail, resolveTenancyPosture, } from '@objectstack/types'; import { postureEnforcesWall } from '@objectstack/spec/security'; +import { SystemObjectName } from '@objectstack/spec/system'; +import { isHumanUserRow } from './audience-posture.js'; /** * The stable NAME of this warning — the "named" half of the ruled "loud, named @@ -127,10 +142,32 @@ export function isDevAdminSeedArmed(): boolean { } /** - * What this deployment has wired that could ever verify an address. Both - * members are resolved by the caller from the live runtime — the services and - * provider config as they stand at `kernel:ready` — because neither is an - * env-only fact. + * [#12751] What the user store says about the declared owner's account at + * boot — the runtime fact that separates "the operator provisioning path will + * verify the owner at creation" from a genuine dead end. + */ +export type WalledOwnerAccountState = + /** Zero human users: the bootstrap first account is still ahead, and an owner-email bootstrap creation is stamped verified. */ + | 'no-human-users' + /** An account holding the declared address exists and IS verified — nothing left to verify. */ + | 'owner-verified' + /** An account holding the declared address exists and is NOT verified — created outside the operator path; the dead end. */ + | 'owner-unverified' + /** Human users exist but none holds the declared address — the bootstrap window is spent. */ + | 'owner-absent' + /** 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; +} + +/** + * What this deployment has wired that could ever verify an address. All + * members are resolved by the caller from the live runtime — the services, + * provider config and user store as they stand at `kernel:ready` — because + * none of them is an env-only fact. */ export interface VerificationPathWiring { /** @@ -145,6 +182,66 @@ export interface VerificationPathWiring { * through one is inserted already verified when the IdP says the address is. */ hasFederatedSignIn: boolean; + /** + * [#12751] The declared owner's account state, from + * {@link probeWalledOwnerAccountState} — pass `'unknown'` when the store is + * not consultable (the predicate then keeps the pre-#12751 loud posture). + */ + ownerAccountState: WalledOwnerAccountState; +} + +/** + * [#12751] Resolve {@link WalledOwnerAccountState} from the live user store. + * + * Mirrors the two reads the elevation gate performs rather than inventing new + * ones: the bounded human-population page (`isBootstrapCreation`'s shape — + * humans, not rows; a FULL page of non-humans cannot prove absence and reads + * as populated) and the by-email owner lookup (both the lowercased and the + * verbatim spelling, matches re-checked trimmed + lowercased, exactly as + * `bootstrapPlatformAdmin` queries). The verified answer is the shared + * [#11343] allow-list (`isEmailVerifiedUserRow`) — the SAME predicate the + * elevation gate refuses on, so this probe can never forecast a refusal the + * gate would not make, nor stay quiet about one it would. + * + * Never throws: any unanswerable read is `'unknown'`. + */ +export async function probeWalledOwnerAccountState( + engine: WalledOwnerProbeEngine | undefined, +): Promise { + const ownerEmail = resolvePlatformOwnerEmail(); + if (!ownerEmail || !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; + return Array.isArray(records) ? (records as Record[]) : []; + }; + try { + const wanted = ownerEmail.toLowerCase(); + const spellings = [...new Set([wanted, ownerEmail])]; + const byId = new Map>(); + for (const spelling of spellings) { + for (const row of asRows( + await engine.find(SystemObjectName.USER, { where: { email: spelling }, limit: 5 }, SYSTEM), + )) { + if (row?.id) byId.set(row.id, row); + } + } + const owners = [...byId.values()].filter( + (row) => + isHumanUserRow(row) && + String((row as { email?: unknown }).email ?? '').trim().toLowerCase() === wanted, + ); + 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'; + } catch { + return 'unknown'; + } } /** The `warn` channel this check needs — every kernel logger satisfies it. */ @@ -154,11 +251,13 @@ export interface WalledOwnerVerificationLogger { /** * The predicate and its message, with no I/O — the whole decision, testable - * shape by shape. + * shape by shape (the caller resolves the probe, {@link + * probeWalledOwnerAccountState}, and hands the result in as wiring). * - * Returns the warning text for the ONE dead-end shape (walled + owner declared - * + no transport + no federated sign-in), or `null` for every other shape. - * Each neighbouring shape is `null` for its own reason: + * Returns the warning text for the dead-end shapes (walled + owner declared + * + no transport + no federated sign-in + an owner account state the + * operator provisioning stamp can no longer reach), or `null` for every + * other shape. Each `null` shape is `null` for its own reason: * * - **not walled** — `single` still promotes the first human user, so a * declared owner that cannot verify costs nothing; @@ -166,9 +265,26 @@ export interface WalledOwnerVerificationLogger { * refuses), and off the boot path the undeclared case has its own refusal; * - **a transport is wired** — the verification link can be delivered; * - **federated sign-in is wired** — the owner can arrive already verified; - * - **the dev-admin seed will stamp this very address** — a dev/harness boot - * verifies its own declared owner at startup (#11343's `email_verified` - * stamp), which is a verification path even with no mailbox anywhere. + * - **the owner's account exists VERIFIED** — nothing left to verify (also + * what stops this warning re-firing on every boot of a healthy, settled + * deployment); + * - **no human users yet** — [#12751] the bootstrap first-account creation + * of the declared owner is stamped verified at creation, so a fresh + * walled boot with nothing wired is no longer a dead end. Two dev-boot + * sub-shapes keep their pre-#12751 answers: a seed armed for the + * declared owner's own address was already `null` (#11343's seed stamp), + * and a seed armed for some OTHER address still WARNS — the seed will + * spend the bootstrap carve-out on a non-owner account at `kernel:ready`, + * before the owner can ever be first. + * + * And each warning shape names its own situation: + * + * - **`owner-unverified`** — the account exists, created outside the + * operator path; the stamp is creation-only, so it stays refused; + * - **`owner-absent`** — the store is populated, the bootstrap window is + * spent, and an invitation-admitted registration arrives UNVERIFIED; + * - **`unknown`** — the store could not be consulted; unanswerable reads + * warn (the pre-#12751 posture: noisy over silent about a dead end). */ export function resolveWalledOwnerVerificationPathWarning( wiring: VerificationPathWiring, @@ -181,25 +297,62 @@ export function resolveWalledOwnerVerificationPathWarning( if (wiring.hasEmailTransport || wiring.hasFederatedSignIn) return null; - // The dev-admin seed provisions the declared owner AND stamps it verified, - // so a dev/harness walled boot is not a dead end even with nothing wired. - // Address-matched on purpose: a dev boot that declares some OTHER owner is - // the dead end this warning is for. - if ( - isDevAdminSeedArmed() && - devSeedAdminEmail().toLowerCase() === ownerEmail.toLowerCase() - ) { - return null; + const state = wiring.ownerAccountState; + if (state === 'owner-verified') return null; + + // The dev-admin seed provisions the declared owner AND stamps it verified + // (#11343) — but only ever on an EMPTY store, so it rescues exactly the + // shapes where the store is empty or unknowable (the verify-harness boots + // that probe nothing). An owner account that already exists unverified, or + // a populated store with no owner account, is past the seed's reach and + // warns below whatever the seed configuration says. Address-matched on + // purpose: a dev boot that declares some OTHER owner still dead-ends (the + // seed spends the bootstrap carve-out on the seed address at kernel:ready, + // so the [#12751] first-account stamp can never reach the owner). + const seedArmed = isDevAdminSeedArmed(); + const seedStampsDeclaredOwner = + seedArmed && devSeedAdminEmail().toLowerCase() === ownerEmail.toLowerCase(); + + if (state === 'no-human-users') { + if (seedStampsDeclaredOwner) return null; + if (!seedArmed) { + // [#12751] Fresh store, no seed in the way: the operator's own first + // account IS the verification path — an owner-email bootstrap creation + // is stamped verified at creation. + return null; + } + // Seed armed for a NON-owner address: it will be first; fall through. } + if (state === 'unknown' && seedStampsDeclaredOwner) return null; + + const situation = + state === 'owner-unverified' + ? 'An account holding that address ALREADY EXISTS and is NOT verified — it was created ' + + 'outside the operator provisioning path (the #12751 stamp applies at operator-provisioned ' + + 'CREATION only), so elevation keeps being refused (walled_owner_not_verified) and the ' + + 'account has no in-product way to satisfy the condition. ' + : state === 'owner-absent' + ? 'Human users already exist but none holds that address, so the first-account bootstrap ' + + 'window (whose owner-email creation would have been stamped verified) is spent; an ' + + 'invitation-admitted registration arrives UNVERIFIED, would be refused ' + + '(walled_owner_not_verified), and would have no in-product way to satisfy the condition. ' + : state === 'no-human-users' + ? `The dev-admin seed is armed and will provision '${devSeedAdminEmail()}' as the FIRST ` + + 'account at kernel:ready, spending the bootstrap carve-out on an address that is not ' + + 'the declared owner — the owner then registers later, is refused ' + + '(walled_owner_not_verified), and has no in-product way to satisfy the condition. ' + : "The user store could not be consulted at boot, so the declared owner's account state " + + 'is unknown; an owner account not created through an operator provisioning path is ' + + 'refused (walled_owner_not_verified) with no in-product way to satisfy the condition. '; return ( `[auth] ${WALLED_OWNER_NO_VERIFICATION_PATH}: tenancy posture '${posture}' declares its ` + `platform owner (${PLATFORM_OWNER_EMAIL_ENV}=${ownerEmail}) but this deployment has NO way ` + 'to verify that address — no email transport is wired AND no trusted federated sign-in ' + '(enterprise SSO or a social/OIDC provider) is configured. Boot continues, but ' + - "platform-admin elevation requires the declared owner's address to be VERIFIED, so the " + - 'owner will register, be refused (walled_owner_not_verified), and have no in-product way ' + - 'to satisfy the condition. Wire EITHER path before the owner registers: (1) an EMAIL ' + + "platform-admin elevation requires the declared owner's address to be VERIFIED. " + + situation + + 'Wire EITHER path: (1) an EMAIL ' + 'TRANSPORT — register an email service (EmailServicePlugin + OS_EMAIL_*), which delivers ' + 'the verification link; or (2) a TRUSTED FEDERATED SIGN-IN — enterprise SSO ' + '(OS_SSO_ENABLED=1 plus a sys_sso_provider row) or a social provider (e.g. ' + diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index 945365e675..d439334f60 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -62,6 +62,7 @@ import { postureEnforcesWall, type PermissionSet } from '@objectstack/spec/secur import { SystemUserId } from '@objectstack/spec/system'; import { PLATFORM_OWNER_EMAIL_ENV, + isEmailVerifiedUserRow, resolvePlatformOwnerEmail, resolveTenancyPosture, } from '@objectstack/types'; @@ -136,19 +137,16 @@ function genId(prefix: string): string { } /** - * [#11343] Verified-email predicate for the walled elevation match — a - * fail-closed ALLOW-LIST over the representations a driver may hand back for - * the `sys_user.email_verified` boolean column (JS `true`, SQLite `1`, and - * their stringified forms). Everything else — `false`/`0`, `null`, an ABSENT - * field on an imported/legacy row, or any representation not listed — reads - * as UNVERIFIED. Absent-means-unverified is deliberate: treating a missing - * column as verified would re-open the exact hole this predicate closes for - * every row that predates the column. + * [#11343] Verified-email predicate for the walled elevation match. + * + * [#12751] The predicate itself moved to `@objectstack/types` + * (`isEmailVerifiedUserRow`) so the owner-verification boot diagnostic in + * `plugin-auth` reads the SAME allow-list this gate refuses on — the + * fail-closed semantics (absent-means-unverified) are documented and pinned + * at the definition. This alias keeps the gate's call sites reading in the + * gate's own vocabulary. */ -function isEmailVerified(u: any): boolean { - const v = u?.email_verified; - return v === true || v === 1 || v === '1' || v === 'true'; -} +const isEmailVerified = isEmailVerifiedUserRow; /** * [#11343] Which `sys_user` writes can change the answer of the elevation diff --git a/packages/types/src/email-verified.test.ts b/packages/types/src/email-verified.test.ts new file mode 100644 index 0000000000..26d2b2b13f --- /dev/null +++ b/packages/types/src/email-verified.test.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11343 / #12751] The verified-email allow-list, pinned representation by + * representation. This predicate is shared between the walled owner-elevation + * gate (which REFUSES on `false`) and the owner-verification boot diagnostic + * (which stays quiet on `true`) — the pin here is what both consumers stand + * on, so any widening or narrowing must happen HERE, deliberately, not in a + * consumer's local copy. + */ + +import { describe, it, expect } from 'vitest'; +import { isEmailVerifiedUserRow } from './email-verified.js'; + +describe('#11343/#12751 — isEmailVerifiedUserRow allow-list', () => { + it('accepts exactly the four listed representations', () => { + expect(isEmailVerifiedUserRow({ email_verified: true })).toBe(true); + expect(isEmailVerifiedUserRow({ email_verified: 1 })).toBe(true); + expect(isEmailVerifiedUserRow({ email_verified: '1' })).toBe(true); + expect(isEmailVerifiedUserRow({ email_verified: 'true' })).toBe(true); + }); + + it('everything else reads as UNVERIFIED — fail closed', () => { + expect(isEmailVerifiedUserRow({ email_verified: false })).toBe(false); + expect(isEmailVerifiedUserRow({ email_verified: 0 })).toBe(false); + expect(isEmailVerifiedUserRow({ email_verified: '0' })).toBe(false); + expect(isEmailVerifiedUserRow({ email_verified: 'false' })).toBe(false); + // Not on the list on purpose: an unrecognized representation must not + // read as verified (e.g. a driver shouting the string). + expect(isEmailVerifiedUserRow({ email_verified: 'TRUE' })).toBe(false); + expect(isEmailVerifiedUserRow({ email_verified: null })).toBe(false); + expect(isEmailVerifiedUserRow({ email_verified: undefined })).toBe(false); + // Absent field (imported/legacy row that predates the column). + expect(isEmailVerifiedUserRow({})).toBe(false); + // Non-object inputs. + expect(isEmailVerifiedUserRow(null)).toBe(false); + expect(isEmailVerifiedUserRow(undefined)).toBe(false); + expect(isEmailVerifiedUserRow('usr_1')).toBe(false); + }); +}); diff --git a/packages/types/src/email-verified.ts b/packages/types/src/email-verified.ts new file mode 100644 index 0000000000..5d6d5290a8 --- /dev/null +++ b/packages/types/src/email-verified.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11343 / #12751] Verified-email predicate over a stored `sys_user` row — a + * fail-closed ALLOW-LIST over the representations a driver may hand back for + * the `sys_user.email_verified` boolean column (JS `true`, SQLite `1`, and + * their stringified forms). Everything else — `false`/`0`, `null`, an ABSENT + * field on an imported/legacy row, or any representation not listed — reads + * as UNVERIFIED. Absent-means-unverified is deliberate: treating a missing + * column as verified would re-open the exact hole this predicate closes for + * every row that predates the column. + * + * ONE resolution, two consumers, by design (#12751): the walled + * platform-admin elevation gate (`plugin-security` + * `bootstrapPlatformAdmin`, where the check REFUSES an unverified owner + * match) and the walled owner-verification boot diagnostic (`plugin-auth` + * `walled-owner-verification-path.ts`, where the check decides whether the + * declared owner's account is already past needing a verification path). + * Those two must answer "is this row verified?" identically — a drift means + * a boot warning that forecasts a refusal the gate will not make, or stays + * silent about one it will. `@objectstack/types` is the shared home both + * packages already resolve `OS_PLATFORM_OWNER_EMAIL` from (`env.ts`). + */ +export function isEmailVerifiedUserRow(row: unknown): boolean { + const v = (row as { email_verified?: unknown } | null | undefined)?.email_verified; + return v === true || v === 1 || v === '1' || v === 'true'; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 32bf0759d6..0e4a597111 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,6 +1,10 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. export * from './degraded-boot.js'; +// [#11343/#12751] The one verified-email predicate the walled owner-elevation +// gate (plugin-security) and the owner-verification boot diagnostic +// (plugin-auth) both read — see the module doc for why it must be one. +export * from './email-verified.js'; export * from './env.js'; export * from './error-leak.js'; // Seek-based pagination for batch walks — the offset alternative that neither