diff --git a/.changeset/audience-posture-invite-only-default.md b/.changeset/audience-posture-invite-only-default.md new file mode 100644 index 0000000000..1c4e98de32 --- /dev/null +++ b/.changeset/audience-posture-invite-only-default.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': minor +'@objectstack/plugin-auth': minor +'@objectstack/verify': patch +--- + +feat(spec,plugin-auth)!: one declared audience posture — `invite_only | email_domain | open`, default `invite_only` + +**BREAKING CHANGE (ships as `minor` under the launch-window rule; every publishable package rides the fixed group).** "Who may become a user of an environment's apps" is now ONE declaration instead of an emergent property of five switches — and its default flips to the safe end. + +- New authorable surface `auth.audience` on `AuthConfig` (`@objectstack/spec/system`): `posture` (`invite_only` | `email_domain` | `open`), `allowedEmailDomains` (required non-empty for `email_domain`), `selfRegistrationPermissionSet` (required whenever the posture permits self-registration; `admin_full_access` refused). Off-vocabulary postures and inert declarations (domains outside `email_domain`, a permission set under `invite_only`) are refused at parse AND at plugin-auth's config entry — never coerced. +- **FROM:** an undeclared audience meant open email/password self-registration with no email verification, and self-registrants implicitly fell back to the `member_default` permission set. **TO:** an undeclared audience IS `invite_only` — self-serve sign-up (email/password, social-provider OAuth JIT, magic-link/OTP/phone/anonymous, and any unclassified creation method) is refused `403 SELF_REGISTRATION_CLOSED` unless the address holds a pending `sys_invitation` (the first account on a fresh install is exempt — the bootstrap bypass). One-line fix for deployments that mean to stay open: declare `auth: { audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' } }`. +- `email_domain` admits only allowlisted domains (`403 EMAIL_DOMAIN_NOT_ALLOWED` otherwise; exact case-insensitive match, subdomains not implied, `+tag` local parts irrelevant). Any self-registration-permitting posture FORCES `requireEmailVerification` on (an explicit `false` beside it is refused at boot) and grants each self-registrant the DECLARED permission set (`sys_user_permission_set`); a declaration that cannot be resolved refuses admission (`403 AUTH_CONFIG_ERROR`) rather than admitting ungranted. +- Operator-driven creation is never posture-gated: admin create-user / bulk import, SCIM provisioning, and JIT through operator-registered identity providers (`oidcProviders`, `@better-auth/sso`) keep working under every posture. +- `/api/v1/auth/config` now serves `features.audiencePosture` and mirrors the forced verification flag; `SELF_REGISTRATION_CLOSED` and `EMAIL_DOMAIN_NOT_ALLOWED` are registered in the ADR-0112 ledger. +- The BOOTSTRAP bypass counts non-system HUMANS, not `sys_user` rows, so a database still carrying the legacy `usr_system` service row is still a fresh install; the same predicate now backs the dev-admin seed's own precondition. The `emailAndPassword.disableSignUp` bootstrap bypass reads it too. +- `@objectstack/verify`: `stack.signUp(...)` seeds a pending `sys_invitation` for the address before signing up, so harness fixtures that mint a second/third identity enter through the invitation carve-out under the new default. Fixtures asserting on their environment's pending invitations should filter by their own `organization_id` (the harness rows carry `org_verify_audience_gate`). + + diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 6afae2efd7..580eace1df 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +285 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +287 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | @@ -140,6 +140,7 @@ const result = ApiErrorSchema.parse(data); * `DRIVER_UNAVAILABLE` * `DUPLICATE_REQUEST` * `ELIGIBILITY_UNEVALUABLE` +* `EMAIL_DOMAIN_NOT_ALLOWED` * `EMAIL_SEND_FAILED` * `EMAIL_SERVICE_REQUIRED` * `ENQUEUE_FAILED` @@ -288,6 +289,7 @@ const result = ApiErrorSchema.parse(data); * `SAML_REGISTER_FAILED` * `SCHEDULES_LIST_FAILED` * `SCHEDULE_DELETE_FAILED` +* `SELF_REGISTRATION_CLOSED` * `SETTINGS_ACTION_FAILED` * `SETTINGS_CRYPTO_UNAVAILABLE` * `SETTINGS_ENGINE_NOT_BOUND` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index ae46388da3..80ee4e0354 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -244,6 +244,7 @@ const result = ErrorCode.parse(data); * `DRIVER_UNAVAILABLE` * `DUPLICATE_REQUEST` * `ELIGIBILITY_UNEVALUABLE` +* `EMAIL_DOMAIN_NOT_ALLOWED` * `EMAIL_SEND_FAILED` * `EMAIL_SERVICE_REQUIRED` * `ENQUEUE_FAILED` @@ -392,6 +393,7 @@ const result = ErrorCode.parse(data); * `SAML_REGISTER_FAILED` * `SCHEDULES_LIST_FAILED` * `SCHEDULE_DELETE_FAILED` +* `SELF_REGISTRATION_CLOSED` * `SETTINGS_ACTION_FAILED` * `SETTINGS_CRYPTO_UNAVAILABLE` * `SETTINGS_ENGINE_NOT_BOUND` diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 5c963ac4f4..33588a5cf6 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1582 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1583 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -31,9 +31,9 @@ counts are sums of the rows they head. Regenerate with | [Security Protocol](/docs/references/security) | 5 | 27 | Permission sets, row-level security, sharing rules, tenancy posture. | | [Shared Protocol](/docs/references/shared) | 8 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | -| [System Protocol](/docs/references/system) | 36 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | +| [System Protocol](/docs/references/system) | 36 | 288 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 152 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **199** | **1582** | 14 protocol modules | +| **Total** | **199** | **1583** | 14 protocol modules | --- @@ -318,14 +318,14 @@ Studio designer metadata — the authoring surfaces for the protocols above. ## System Protocol -**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **36 pages, 287 schemas** +**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **36 pages, 288 schemas** The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | File | Schemas | | :--- | :--- | | [`app-install.zod.ts`](/docs/references/system/app-install) | `AppCompatibilityCheck`, `AppInstallRequest`, `AppInstallResult`, `AppManifest` | -| [`auth-config.zod.ts`](/docs/references/system/auth-config) | `AdvancedAuthConfig`, `AuthConfig`, `AuthPluginConfig`, `AuthProviderConfig`, `EmailAndPasswordConfig`, `EmailVerificationConfig`, `MutualTLSConfig`, `OidcProviderConfig`, `OidcProvidersConfig`, `SocialProviderConfig` | +| [`auth-config.zod.ts`](/docs/references/system/auth-config) | `AdvancedAuthConfig`, `AudienceConfig`, `AuthConfig`, `AuthPluginConfig`, `AuthProviderConfig`, `EmailAndPasswordConfig`, `EmailVerificationConfig`, `MutualTLSConfig`, `OidcProviderConfig`, `OidcProvidersConfig`, `SocialProviderConfig` | | [`book.zod.ts`](/docs/references/system/book) | `Book`, `BookAudience`, `BookGroup`, `BookInclude`, `BookNode` | | [`cache.zod.ts`](/docs/references/system/cache) | `CacheAvalanchePrevention`, `CacheConfig`, `CacheConsistency`, `CacheInvalidation`, `CacheStrategy`, `CacheTier`, `CacheWarmup`, `DistributedCacheConfig` | | [`change-management.zod.ts`](/docs/references/system/change-management) | `ChangeImpact`, `ChangePriority`, `ChangeRequest`, `ChangeStatus`, `ChangeType`, `RollbackPlan` | diff --git a/content/docs/references/system/auth-config.mdx b/content/docs/references/system/auth-config.mdx index 20c13803c0..fce2eb6c96 100644 --- a/content/docs/references/system/auth-config.mdx +++ b/content/docs/references/system/auth-config.mdx @@ -17,8 +17,8 @@ Used in server-side configuration injection. ## TypeScript Usage ```typescript -import { AdvancedAuthConfigSchema, AuthConfigSchema, AuthPluginConfigSchema, AuthProviderConfigSchema, EmailAndPasswordConfigSchema, EmailVerificationConfigSchema, MutualTLSConfigSchema, OidcProviderConfigSchema, OidcProvidersConfigSchema, SocialProviderConfigSchema } from '@objectstack/spec/system'; -import type { AdvancedAuthConfig, AuthConfig, AuthPluginConfig, AuthProviderConfig, EmailAndPasswordConfig, EmailVerificationConfig, MutualTLSConfig, OidcProviderConfig, OidcProvidersConfig, SocialProviderConfig } from '@objectstack/spec/system'; +import { AdvancedAuthConfigSchema, AudienceConfigSchema, AuthConfigSchema, AuthPluginConfigSchema, AuthProviderConfigSchema, EmailAndPasswordConfigSchema, EmailVerificationConfigSchema, MutualTLSConfigSchema, OidcProviderConfigSchema, OidcProvidersConfigSchema, SocialProviderConfigSchema } from '@objectstack/spec/system'; +import type { AdvancedAuthConfig, AudienceConfig, AuthConfig, AuthPluginConfig, AuthProviderConfig, EmailAndPasswordConfig, EmailVerificationConfig, MutualTLSConfig, OidcProviderConfig, OidcProvidersConfig, SocialProviderConfig } from '@objectstack/spec/system'; // Validate data const result = AdvancedAuthConfigSchema.parse(data); @@ -40,6 +40,19 @@ Advanced / low-level Better-Auth options | **cookiePrefix** | `string` | optional | Prefix for auth cookie names | +--- + +## AudienceConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **posture** | `Enum<'invite_only' \| 'email_domain' \| 'open'>` | optional (default: `"invite_only"`) | Who may self-register into this environment: invite_only (default — operator acts only), email_domain (allowlisted email domains), or open (anyone). Any posture other than invite_only forces email verification on. | +| **allowedEmailDomains** | `string[]` | optional | Email domains admitted to self-register under posture email_domain (exact, case-insensitive match; subdomains need their own entries). Required non-empty for email_domain; refused under other postures. | +| **selfRegistrationPermissionSet** | `string` | optional | sys_permission_set name granted to each self-registrant. Required when posture is email_domain or open; refused for invite_only. admin_full_access is refused. | + + --- ## AuthConfig @@ -60,6 +73,7 @@ Advanced / low-level Better-Auth options | **oidcProviders** | `{ providerId: string; name?: string; discoveryUrl?: string; issuer?: string; … }[]` | optional | List of OIDC/OAuth2 providers for enterprise SSO. Product or enterprise packages can pass this directly or contribute it through auth:configure. | | **emailAndPassword** | `{ enabled: boolean; disableSignUp?: boolean; requireEmailVerification?: boolean; minPasswordLength?: number; … }` | optional | Email and password authentication options forwarded to better-auth | | **emailVerification** | `{ sendOnSignUp?: boolean; sendOnSignIn?: boolean; autoSignInAfterVerification?: boolean; expiresIn?: number }` | optional | Email verification options forwarded to better-auth | +| **audience** | `{ posture: Enum<'invite_only' \| 'email_domain' \| 'open'>; allowedEmailDomains?: string[]; selfRegistrationPermissionSet?: string }` | optional | Audience posture: who may self-register into this environment (invite_only — the default — \| email_domain \| open). See AudienceConfigSchema. | | **advanced** | `{ crossSubDomainCookies?: object; useSecureCookies?: boolean; disableCSRFCheck?: boolean; cookiePrefix?: string }` | optional | Advanced / low-level Better-Auth options | | **ssoOnlyMode** | `boolean` | optional | SSO-only login: hide the local password form + self-registration (the break-glass password endpoint stays enabled) | | **mutualTls** | `{ enabled: boolean; clientCertRequired: boolean; trustedCAs: string[]; crlUrl?: string; … }` | optional | Mutual TLS (mTLS) configuration | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 81f80c1aa1..7ba3c903c1 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -264,4 +264,4 @@ directory rather than per file. | `kernel/` | 296 | | `qa/` | 6 | | `shared/` | 20 | -| `system/` | 360 | +| `system/` | 361 | diff --git a/packages/plugins/plugin-auth/src/accept-invitation-adopt-membership.test.ts b/packages/plugins/plugin-auth/src/accept-invitation-adopt-membership.test.ts index 3d0e4a1b3e..35c66b3561 100644 --- a/packages/plugins/plugin-auth/src/accept-invitation-adopt-membership.test.ts +++ b/packages/plugins/plugin-auth/src/accept-invitation-adopt-membership.test.ts @@ -23,6 +23,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql'; import { AuthManager } from './auth-manager'; +import { inviteForAudienceGate } from './audience-gate-test-support'; const SECRET = 'test-secret-at-least-32-chars-long!!'; const BASE = 'http://localhost:3000'; @@ -178,6 +179,9 @@ const post = (manager: AuthManager, path: string, body: unknown, cookie?: string /** Sign a user up and return their session cookie + user id. */ const signUp = async (manager: AuthManager, engine: MemoryEngine, email: string) => { + // [#11739] default posture invite_only: fixture users beyond the first + // enter through the invitation carve-out (see audience-gate-test-support). + inviteForAudienceGate(engine, email); const res = await post(manager, '/sign-up/email', { email, password: PASSWORD, name: email }); expect(res.status, await res.clone().text()).toBe(200); const user = (engine.tables.get('sys_user') ?? []).find((u) => u.email === email); diff --git a/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts b/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts index fa021a6b55..d4834512e8 100644 --- a/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts +++ b/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts @@ -44,6 +44,7 @@ import { AuthManager } from './auth-manager'; import { createMemoryEngine } from './impersonation-bearer-rotation.test'; import { ADMIN_SESSION_RECOVERY_RESPONSE_HEADER } from './impersonation-bearer-rotation'; import { USER_NOT_FOUND } from './admin-impersonate-endpoint'; +import { inviteForAudienceGate } from './audience-gate-test-support'; const SECRET = 'test-secret-at-least-32-chars-long!!'; const PASSWORD = 'S3cure!Passw0rd-9968'; @@ -58,14 +59,18 @@ const makeManager = (engine: any) => plugins: { admin: true }, } as any); -const signUp = (manager: AuthManager, email: string, name: string) => - manager.handleRequest( +const signUp = (manager: AuthManager, email: string, name: string) => { + // [#11739] default posture invite_only: fixture users beyond the first + // enter through the invitation carve-out (see audience-gate-test-support). + inviteForAudienceGate(manager, email); + return manager.handleRequest( new Request(`${BASE}/sign-up/email`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password: PASSWORD, name }), }), ); +}; const signIn = (manager: AuthManager, email: string) => manager.handleRequest( diff --git a/packages/plugins/plugin-auth/src/admin-revoke-user-session-match-guard.test.ts b/packages/plugins/plugin-auth/src/admin-revoke-user-session-match-guard.test.ts index 3eeb0782cb..8cb07bbaff 100644 --- a/packages/plugins/plugin-auth/src/admin-revoke-user-session-match-guard.test.ts +++ b/packages/plugins/plugin-auth/src/admin-revoke-user-session-match-guard.test.ts @@ -28,6 +28,7 @@ import { adminMayRevokeUserSessions, anySessionCarriesToken, } from './admin-revoke-user-session-match-guard'; +import { inviteForAudienceGate } from './audience-gate-test-support'; /** * In-memory IDataEngine — the `session-tombstone.test.ts` harness, unchanged, @@ -142,8 +143,12 @@ const post = (manager: AuthManager, path: string, cookie?: string, body?: unknow }), ); -const signUp = (manager: AuthManager, email: string) => - post(manager, 'sign-up/email', undefined, { email, password: PASSWORD, name: 'AdminRevoke' }); +const signUp = (manager: AuthManager, email: string) => { + // [#11739] default posture invite_only: fixture users beyond the first + // enter through the invitation carve-out (see audience-gate-test-support). + inviteForAudienceGate(manager, email); + return post(manager, 'sign-up/email', undefined, { email, password: PASSWORD, name: 'AdminRevoke' }); +}; const signIn = (manager: AuthManager, email: string) => post(manager, 'sign-in/email', undefined, { email, password: PASSWORD }); diff --git a/packages/plugins/plugin-auth/src/audience-bootstrap-seam.test.ts b/packages/plugins/plugin-auth/src/audience-bootstrap-seam.test.ts new file mode 100644 index 0000000000..83ad5e0690 --- /dev/null +++ b/packages/plugins/plugin-auth/src/audience-bootstrap-seam.test.ts @@ -0,0 +1,265 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11767] The audience gate's BOOTSTRAP BYPASS, pinned at the seam that + * carries it — over a REAL `ObjectQL` engine. + * + * ## What shipped, and why every existing test stayed green + * + * #11739 made `invite_only` the default audience posture and enforced it at + * better-auth's `user.validateUserInfo` seam. The declared carve-out is that a + * fresh install never locks its operator out: with no users yet, the first + * account is admitted under every posture. `decideAudienceAdmission` (pure) + * implemented that correctly and a full matrix pinned it. The WIRING did not, + * and the wiring had no pin of its own: + * + * `isBootstrapCreation` probed `ctx.context.adapter.findOne({ model: 'user', + * where: [] })`. `where: []` lowers to an empty filter, and the real engine's + * `requireFindOnePredicate` (#4419) REFUSES a `findOne` that selects no + * particular record — it throws rather than returning an arbitrary row. The + * surrounding `catch { return false; }` turned that refusal into "not + * bootstrap", so EVERY bootstrap creation was refused with + * `SELF_REGISTRATION_CLOSED`, including the dev-admin seed's own. + * + * The in-memory engine double used by the sibling matrix has no such guard: it + * treats an absent filter as "match everything", returns `null` on an empty + * table, and reports bootstrap correctly. So the defect was invisible to every + * unit suite and surfaced only when the Dogfood Regression Gate and the verify + * harness booted real stacks whose fixtures all start from `stack.signIn()` on + * the seeded dev admin. + * + * ## Why this file uses a real engine, and drives the SERVER-SIDE lane + * + * Two deliberate choices, each aimed at one half of what went unmeasured: + * + * 1. **A real `ObjectQL` over `@objectstack/driver-sql` + better-sqlite3 + * `:memory:`** — the same backend the at-rest pins use. `requireFindOnePredicate` + * is the mechanism that broke this; a fake without it cannot pin the fix. + * Case ⓪ asserts that mechanism directly, so the reason these cases are + * non-vacuous is itself under test rather than merely asserted in prose. + * 2. **`api.signUpEmail({ body })`, not an HTTP request** — that is the exact + * call the dev-admin seed makes (`auth-plugin.ts` `maybeSeedDevAdmin`), and + * it reaches `validateUserInfo` through better-auth's server-side endpoint + * context rather than a request. Case ① is that lane; case ② keeps the HTTP + * lane beside it so a future change cannot fix one and break the other. + * + * ## The population question, pinned as a decision + * + * "Bootstrap" counts NON-SYSTEM HUMANS, not `sys_user` rows — see + * {@link isHumanUserRow} for the argument. Case ④ is that decision's pin: a + * database still carrying the legacy `usr_system` service row (no longer + * provisioned, but present in every DB an older runtime created) is still a + * bootstrap, because plugin-security's first-user detection and the dev seed's + * own precondition both say so — and a gate that disagreed with them would + * refuse the very sign-up plugin-security stands ready to promote. + * + * Case ③ is the control that keeps the bypass honest: it must not have become + * "always admit". + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { AuthManager } from './auth-manager.js'; +import { SELF_REGISTRATION_CLOSED, isHumanUserRow } from './audience-posture.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-11767'; +const PASSWORD = 'S3cure!Passw0rd-11767'; + +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); +} + +/** Every `sys_user` row, read below the gate. */ +async function readUsers(engine: ObjectQL): Promise[]> { + const rows = await engine.find( + 'sys_user', + { limit: 100 }, + { context: { isSystem: true } } as never, + ); + return (Array.isArray(rows) ? rows : []) as Record[]; +} + +/** + * The dev-admin seed's OWN lane: better-auth's server-side API, not an HTTP + * request (`auth-plugin.ts` → `api.signUpEmail({ body })`). + */ +async function seedLaneSignUp( + manager: AuthManager, + email: string, +): Promise<{ ok: boolean; message: string }> { + const api = (await manager.getApi()) as unknown as { + signUpEmail(input: { body: Record }): Promise; + }; + try { + await api.signUpEmail({ body: { email, password: PASSWORD, name: 'Bootstrap Operator' } }); + return { ok: true, message: '' }; + } catch (error: unknown) { + const e = error as { message?: string; body?: { message?: string; code?: string } }; + return { ok: false, message: e?.body?.message ?? e?.message ?? String(error) }; + } +} + +/** The HTTP lane, for the same question. */ +function httpSignUp(manager: AuthManager, email: string): Promise { + return manager.handleRequest( + new Request(`${AUTH}/sign-up/email`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: BASE }, + body: JSON.stringify({ email, password: PASSWORD, name: 'Bootstrap Operator' }), + }), + ); +} + +describe('[#11767] the bootstrap bypass fires at the real seam', () => { + it('⓪ the MECHANISM: the real engine REFUSES a predicate-less findOne (#4419) — which is why a fake cannot pin this', async () => { + const engine = await bootEngine(); + // This is the shape `isBootstrapCreation` used to ask through the + // better-auth adapter. It does not answer "no users"; it throws — and a + // `catch` around it reads as "users exist". + await expect( + engine.findOne('sys_user', { where: {} }, { context: { isSystem: true } } as never), + ).rejects.toThrow(/selects no particular record/i); + // The answerable form the fix uses, on the same empty table. + const page = await engine.find( + 'sys_user', + { limit: 50 }, + { context: { isSystem: true } } as never, + ); + expect(page).toEqual([]); + }); + + it('① THE DEFECT: the dev-seed lane (server-side api.signUpEmail) is ADMITTED on a zero-user database', async () => { + const engine = await bootEngine(); + const manager = makeManager(engine); + + const result = await seedLaneSignUp(manager, 'admin@objectos.ai'); + + expect(result.ok, `the operator's own first account was refused: ${result.message}`).toBe(true); + expect(result.message).not.toContain('Self-registration is closed'); + const users = await readUsers(engine); + expect(users.map((u) => u.email)).toEqual(['admin@objectos.ai']); + }); + + it('② the HTTP lane answers the same on a zero-user database', async () => { + const engine = await bootEngine(); + const manager = makeManager(engine); + + const res = await httpSignUp(manager, 'owner@example.com'); + + expect(res.status, `sign-up/email refused: ${await res.clone().text()}`).toBeLessThan(300); + expect((await readUsers(engine)).length).toBe(1); + }); + + it('③ CONTROL: the bypass did not become "always admit" — a second self-serve signup is still refused 403', async () => { + const engine = await bootEngine(); + const manager = makeManager(engine); + + expect((await seedLaneSignUp(manager, 'admin@objectos.ai')).ok).toBe(true); + const res = await httpSignUp(manager, 'stranger@example.com'); + + expect(res.status).toBe(403); + const body = (await res.json()) as { code?: string; message?: string }; + expect(body.code).toBe(SELF_REGISTRATION_CLOSED); + // The refusal really refused. + expect((await readUsers(engine)).length).toBe(1); + }); + + it('④ THE POPULATION DECISION: a legacy usr_system row is not a human — the first human sign-up is still bootstrap', async () => { + const engine = await bootEngine(); + // The service account an older runtime provisioned. It is NOT a human, and + // plugin-security's first-user detection agrees (it would still promote the + // next sign-up to platform admin). + await engine.insert( + 'sys_user', + { id: 'usr_system', email: 'system@localhost', name: 'System', role: 'system' }, + { context: { isSystem: true } } as never, + ); + const manager = makeManager(engine); + + const result = await seedLaneSignUp(manager, 'admin@objectos.ai'); + + expect(result.ok, `a legacy usr_system row locked the operator out: ${result.message}`).toBe( + true, + ); + const users = await readUsers(engine); + expect(users.filter(isHumanUserRow).map((u) => u.email)).toEqual(['admin@objectos.ai']); + // …and now that a human exists, the gate closes again. + const second = await httpSignUp(manager, 'stranger@example.com'); + expect(second.status).toBe(403); + expect(((await second.json()) as { code?: string }).code).toBe(SELF_REGISTRATION_CLOSED); + }); + + it('⑤ the SIBLING bypass — disableSignUp — fires on the same probe, and re-closes once the operator exists', async () => { + const engine = await bootEngine(); + const manager = makeManager(engine, { emailAndPassword: { disableSignUp: true } }); + + const first = await httpSignUp(manager, 'owner@example.com'); + expect(first.status, `the disableSignUp bootstrap bypass did not fire: ${await first.clone().text()}`).toBeLessThan(300); + + const second = await httpSignUp(manager, 'stranger@example.com'); + expect(second.status).toBeGreaterThanOrEqual(400); + expect((await readUsers(engine)).length).toBe(1); + }); +}); diff --git a/packages/plugins/plugin-auth/src/audience-gate-test-support.ts b/packages/plugins/plugin-auth/src/audience-gate-test-support.ts new file mode 100644 index 0000000000..5c05a66eba --- /dev/null +++ b/packages/plugins/plugin-auth/src/audience-gate-test-support.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * TEST SUPPORT for the [#11739] audience-posture gate — not part of the + * published plugin surface (no tsup entry reaches it; only `*.test.ts` files + * import it). + * + * Since #11739 the platform's DEFAULT audience posture is `invite_only`: + * only the very first account (zero users — the bootstrap bypass) may + * self-register on an undeclared config, and every further self-serve + * sign-up needs a pending invitation, an allowlisted email domain, or an + * `open` posture. The in-memory harness suites in this package create their + * second/third fixture users through the real better-auth sign-up route, so + * they enter the way real users now do: holding a pending invitation. + * + * This is deliberately the INVITATION lane rather than an `open` posture on + * the fixture config: `open` (and `email_domain`) force + * `requireEmailVerification` on, which stops sign-up from minting the very + * sessions these suites exist to exercise — and the invitation lane keeps + * the audience gate itself honestly on the path (the carve-out is a real + * admission verdict, not a bypass). + */ + +/** The memory-engine shape every harness in this package shares. */ +interface TablesEngine { + tables: Map; +} + +/** + * Seed a pending, unexpired `sys_invitation` row for `email` so the audience + * gate admits its self-serve sign-up. Idempotent enough for fixtures (each + * call adds one pending row; the gate only asks "does one exist"). + * + * Accepts either the engine itself or an `AuthManager` (whose + * `config.dataEngine` is the engine) so each file's `signUp` helper can call + * it with whatever it already holds. Two engine shapes are served: + * + * - the `tables`-Map memory harness: seeded SYNCHRONOUSLY (safe to call + * without awaiting — the Map mutation completes before this returns); + * - a real `IDataEngine` (the sqlite/ObjectQL suites): seeded via + * `insert(...)` under the system context — AWAIT the returned promise + * there, or the seed races the sign-up. + */ +export function inviteForAudienceGate(engineOrManager: unknown, email: string): Promise { + const row = { + id: `inv_audience_${Math.random().toString(36).slice(2, 10)}`, + email, + status: 'pending', + // A dedicated org id so suites that count THEIR invitations per + // organization never see these rows. + organization_id: 'org_audience_gate', + role: 'member', + inviter_id: 'usr_audience_gate', + expires_at: new Date(Date.now() + 3_600_000), + }; + const tablesEngine = resolveTablesEngine(engineOrManager); + if (tablesEngine) { + const rows = tablesEngine.tables.get('sys_invitation') ?? []; + tablesEngine.tables.set('sys_invitation', [...rows, row]); + return Promise.resolve(); + } + const engine = resolveInsertEngine(engineOrManager); + if (!engine) return Promise.resolve(); + return Promise.resolve( + engine.insert('sys_invitation', row, { context: { isSystem: true } }), + ).then(() => undefined); +} + +function resolveTablesEngine(engineOrManager: unknown): TablesEngine | null { + const direct = engineOrManager as TablesEngine & { config?: { dataEngine?: TablesEngine } }; + if (direct?.tables instanceof Map) return direct; + const viaManager = (direct as any)?.config?.dataEngine; + if (viaManager?.tables instanceof Map) return viaManager; + return null; +} + +function resolveInsertEngine( + engineOrManager: unknown, +): { insert: (name: string, data: any, options?: any) => Promise } | null { + const direct = engineOrManager as any; + if (typeof direct?.insert === 'function') return direct; + const viaManager = direct?.config?.dataEngine; + if (typeof viaManager?.insert === 'function') return viaManager; + return null; +} diff --git a/packages/plugins/plugin-auth/src/audience-posture.test.ts b/packages/plugins/plugin-auth/src/audience-posture.test.ts new file mode 100644 index 0000000000..0e4030f0b3 --- /dev/null +++ b/packages/plugins/plugin-auth/src/audience-posture.test.ts @@ -0,0 +1,641 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#11739] Audience posture — the declared answer to "who may become a user of +// this environment", enforced at better-auth's `user.validateUserInfo` seam. +// +// Three layers, matching the module split: +// +// 1. PURE decision matrix — `decideAudienceAdmission` / `classifyCreationMethod` +// drive the full posture × creation-method table as direct calls, including +// the paths an HTTP harness cannot cheaply reach (SSO JIT, SCIM, unknown +// methods). +// 2. ENTRY validation — `assertAudienceConfig` at the constructor and +// `applyConfigPatch`: off-vocabulary postures, inert declarations +// (ADR-0078), the open-with-verification-off contradiction. +// 3. END of the chain (the #4785 lesson: assert the outcome, not the middle) — +// a real better-auth pipeline over the in-memory engine: refusals carry the +// REGISTERED code + 403 status on the wire, admissions mint accounts, and +// an admitted self-registrant really receives the declared permission set +// (`sys_user_permission_set` row), because a declaration nothing lands is +// the ADR-0078 defect this card closes. + +import { describe, it, expect, vi } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql'; +import { ERROR_CODE_LEDGER } from '@objectstack/spec/api'; +import { AuthManager } from './auth-manager'; +import { + assertAudienceConfig, + classifyCreationMethod, + decideAudienceAdmission, + emailDomainAllowed, + extractEmailDomain, + resolveAudience, + SELF_REGISTRATION_CLOSED, + EMAIL_DOMAIN_NOT_ALLOWED, + AUDIENCE_CONFIG_ERROR, + type ResolvedAudience, +} from './audience-posture'; + +// ── In-memory IDataEngine (the #3585 / session-of-record harness shape) ────── + +const createMemoryEngine = () => { + const tables = new Map(); + const rows = (name: string) => { + if (!tables.has(name)) tables.set(name, []); + return tables.get(name)!; + }; + const eq = (a: any, b: any) => + a instanceof Date || b instanceof Date + ? new Date(a as any).getTime() === new Date(b as any).getTime() + : a === b; + const matches = (row: any, where: Record = {}) => + Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + const actual = row[k]; + if (v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Date)) { + if ('$ne' in v) return !eq(actual, v.$ne); + if ('$in' in v) return (v.$in as any[]).some((x) => eq(actual, x)); + if ('$gt' in v) return actual > v.$gt; + if ('$gte' in v) return actual >= v.$gte; + if ('$lt' in v) return actual < v.$lt; + if ('$lte' in v) return actual <= v.$lte; + } + return eq(actual, v); + }); + const project = (row: any, fields?: string[]) => { + if (!Array.isArray(fields) || fields.length === 0) return { ...row }; + const out: any = {}; + for (const f of ['id', ...fields]) if (f in row) out[f] = row[f]; + return out; + }; + let seq = 0; + return { + tables, + async insert(name: string, data: any) { + const row = { id: data.id ?? `row_${++seq}`, ...data }; + rows(name).push(row); + return { ...row }; + }, + async findOne(name: string, q: any = {}) { + const row = rows(name).find((r) => matches(r, q.where)); + return row ? project(row, q.fields) : null; + }, + async find(name: string, q: any = {}) { + let out = rows(name).filter((r) => matches(r, q.where)); + if (q.offset) out = out.slice(q.offset); + if (q.limit) out = out.slice(0, q.limit); + return out.map((r) => project(r, q.fields)); + }, + async count(name: string, q: any = {}) { + return rows(name).filter((r) => matches(r, q.where)).length; + }, + async update(name: string, patch: any, options?: any) { + assertEngineUpdateDispatch(patch, options); + const row = rows(name).find((r) => r.id === patch.id); + if (!row) return null; + Object.assign(row, patch); + return { ...row }; + }, + async delete(name: string, q: any = {}) { + assertEngineDeleteDispatch(q); + const table = rows(name); + const keep = table.filter((r) => !matches(r, q.where)); + tables.set(name, keep); + return table.length - keep.length; + }, + }; +}; + +const SECRET = 'test-secret-at-least-32-chars-long!!'; +const PASSWORD = 'S3cure!Passw0rd-4785'; + +const makeManager = (engine: any, config: Record = {}) => + new AuthManager({ + secret: SECRET, + baseUrl: 'http://localhost:3000', + dataEngine: engine, + ...config, + } as any); + +const signUp = (manager: AuthManager, email: string) => + manager.handleRequest( + new Request('http://localhost:3000/api/v1/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password: PASSWORD, name: 'Audience Test' }), + }), + ); + +/** Seed one existing user so the zero-user bootstrap bypass is OFF. */ +const seedExistingUser = (engine: any) => { + engine.tables.set('sys_user', [ + { + id: 'usr_existing', + email: 'owner@example.com', + name: 'Owner', + email_verified: true, + created_at: new Date(), + updated_at: new Date(), + }, + ]); +}; + +const seedPermissionSet = (engine: any, name: string, extra: Record = {}) => { + engine.tables.set('sys_permission_set', [ + ...(engine.tables.get('sys_permission_set') ?? []), + { id: `ps_${name}`, name, label: name, active: true, ...extra }, + ]); +}; + +const seedPendingInvitation = (engine: any, email: string, extra: Record = {}) => { + engine.tables.set('sys_invitation', [ + ...(engine.tables.get('sys_invitation') ?? []), + { + id: `inv_${Math.random().toString(36).slice(2, 8)}`, + email, + status: 'pending', + organization_id: 'org_1', + expires_at: new Date(Date.now() + 60_000), + ...extra, + }, + ]); +}; + +const audience = (over: Partial = {}): ResolvedAudience => ({ + posture: 'invite_only', + allowedEmailDomains: [], + ...over, +}); + +// ───────────────────────────────────────────────────────────────────────────── + +describe('pinned domain matching (#11739)', () => { + it('rule 1: the domain is everything after the LAST @; no @ / empty ⇒ no match ever', () => { + expect(extractEmailDomain('user@acme.com')).toBe('acme.com'); + expect(extractEmailDomain('we"ird@user@acme.com')).toBe('acme.com'); + expect(extractEmailDomain('no-at-sign')).toBeNull(); + expect(extractEmailDomain('trailing@')).toBeNull(); + expect(extractEmailDomain(undefined)).toBeNull(); + expect(emailDomainAllowed('no-at-sign', ['acme.com'])).toBe(false); + }); + + it('rule 2: case-insensitive on both sides', () => { + expect(emailDomainAllowed('user@ACME.com', ['acme.com'])).toBe(true); + expect(emailDomainAllowed('user@acme.com', ['AcMe.CoM'])).toBe(true); + }); + + it('rule 3: exact equality — subdomains are NOT implied, in either direction', () => { + expect(emailDomainAllowed('user@mail.acme.com', ['acme.com'])).toBe(false); + expect(emailDomainAllowed('user@acme.com', ['mail.acme.com'])).toBe(false); + expect(emailDomainAllowed('user@mail.acme.com', ['mail.acme.com'])).toBe(true); + // A suffix that is not a label boundary must not match either. + expect(emailDomainAllowed('user@evilacme.com', ['acme.com'])).toBe(false); + }); + + it('rule 4: +tag local parts are irrelevant (matching never reads the local part)', () => { + expect(emailDomainAllowed('user+anything@acme.com', ['acme.com'])).toBe(true); + expect(emailDomainAllowed('user+tag@other.com', ['acme.com'])).toBe(false); + }); + + it('placeholder / phone-style addresses carry no matchable domain', () => { + expect(emailDomainAllowed('', ['acme.com'])).toBe(false); + }); +}); + +describe('creation-method classification (#11739)', () => { + const enterprise = { enterpriseOAuthProviderIds: new Set(['okta', 'objectstack-cloud']) }; + + it('operator methods are never posture-gated: admin, scim', () => { + expect(classifyCreationMethod({ method: 'admin' }, enterprise)).toBe('operator'); + expect(classifyCreationMethod({ method: 'scim' }, enterprise)).toBe('operator'); + }); + + it('operator-registered identity authorities are provider class: sso-oidc, sso-saml, enterprise oauth', () => { + expect(classifyCreationMethod({ method: 'sso-oidc' }, enterprise)).toBe('provider'); + expect(classifyCreationMethod({ method: 'sso-saml' }, enterprise)).toBe('provider'); + expect(classifyCreationMethod({ method: 'oauth', oauth: { providerId: 'okta' } }, enterprise)).toBe('provider'); + expect( + classifyCreationMethod({ method: 'oauth', oauth: { providerId: 'objectstack-cloud' } }, enterprise), + ).toBe('provider'); + }); + + it('self-serve methods are posture-gated: email-password, social oauth, magic-link, email-otp, phone, anonymous, siwe', () => { + for (const method of ['email-password', 'magic-link', 'email-otp', 'phone-number', 'anonymous', 'siwe']) { + expect(classifyCreationMethod({ method }, enterprise)).toBe('self-serve'); + } + expect(classifyCreationMethod({ method: 'oauth', oauth: { providerId: 'google' } }, enterprise)).toBe('self-serve'); + }); + + it('an UNRECOGNIZED method is self-serve — fail closed, a future creation path must not bypass the wall', () => { + expect(classifyCreationMethod({ method: 'future-thing' }, enterprise)).toBe('self-serve'); + expect(classifyCreationMethod({}, enterprise)).toBe('self-serve'); + expect(classifyCreationMethod(undefined, enterprise)).toBe('self-serve'); + }); +}); + +describe('admission decision matrix (#11739): posture × creation class', () => { + const base = { email: 'user@acme.com', hasPendingInvitation: false, isBootstrap: false }; + + it.each([ + ['invite_only', 'operator'], + ['invite_only', 'provider'], + ['email_domain', 'operator'], + ['email_domain', 'provider'], + ['open', 'operator'], + ['open', 'provider'], + ] as const)('%s admits the %s class (admin-create / SSO JIT are operator-sanctioned)', (posture, cls) => { + const verdict = decideAudienceAdmission({ + ...base, + audience: audience({ posture, allowedEmailDomains: posture === 'email_domain' ? ['acme.com'] : [] }), + creationClass: cls, + }); + expect(verdict).toEqual({ admit: true, grantPermissionSet: false }); + }); + + it('invite_only refuses self-serve with SELF_REGISTRATION_CLOSED', () => { + const verdict = decideAudienceAdmission({ ...base, audience: audience(), creationClass: 'self-serve' }); + expect(verdict.admit).toBe(false); + if (!verdict.admit) expect(verdict.code).toBe(SELF_REGISTRATION_CLOSED); + }); + + it('invite_only admits a self-serve creation holding a pending invitation (the carve-out)', () => { + const verdict = decideAudienceAdmission({ + ...base, + hasPendingInvitation: true, + audience: audience(), + creationClass: 'self-serve', + }); + expect(verdict).toEqual({ admit: true, grantPermissionSet: false }); + }); + + it('email_domain admits on-list self-serve WITH the permission-set grant, refuses off-list with EMAIL_DOMAIN_NOT_ALLOWED', () => { + const aud = audience({ + posture: 'email_domain', + allowedEmailDomains: ['acme.com'], + selfRegistrationPermissionSet: 'portal_user', + }); + expect(decideAudienceAdmission({ ...base, audience: aud, creationClass: 'self-serve' })).toEqual({ + admit: true, + grantPermissionSet: true, + }); + const refused = decideAudienceAdmission({ + ...base, + email: 'user@other.com', + audience: aud, + creationClass: 'self-serve', + }); + expect(refused.admit).toBe(false); + if (!refused.admit) expect(refused.code).toBe(EMAIL_DOMAIN_NOT_ALLOWED); + }); + + it('an explicit invitation trumps the email_domain allowlist (no invite dead-end for external invitees)', () => { + const verdict = decideAudienceAdmission({ + ...base, + email: 'contractor@external.com', + hasPendingInvitation: true, + audience: audience({ posture: 'email_domain', allowedEmailDomains: ['acme.com'], selfRegistrationPermissionSet: 'portal_user' }), + creationClass: 'self-serve', + }); + expect(verdict).toEqual({ admit: true, grantPermissionSet: false }); + }); + + it('open admits self-serve with the grant', () => { + const verdict = decideAudienceAdmission({ + ...base, + audience: audience({ posture: 'open', selfRegistrationPermissionSet: 'portal_user' }), + creationClass: 'self-serve', + }); + expect(verdict).toEqual({ admit: true, grantPermissionSet: true }); + }); + + it('bootstrap (zero users) admits self-serve under every posture — a fresh install never locks its operator out', () => { + for (const posture of ['invite_only', 'email_domain', 'open'] as const) { + const verdict = decideAudienceAdmission({ + email: 'owner@anything.com', + hasPendingInvitation: false, + isBootstrap: true, + audience: audience({ + posture, + allowedEmailDomains: posture === 'email_domain' ? ['acme.com'] : [], + selfRegistrationPermissionSet: posture === 'invite_only' ? undefined : 'portal_user', + }), + creationClass: 'self-serve', + }); + expect(verdict).toEqual({ admit: true, grantPermissionSet: false }); + } + }); + + it('an off-vocabulary posture refuses with AUTH_CONFIG_ERROR — a verdict distinct from "policy said no" (#5205)', () => { + const verdict = decideAudienceAdmission({ + ...base, + audience: audience({ invalid: { raw: "'inviteOnly'" } }), + creationClass: 'self-serve', + }); + expect(verdict.admit).toBe(false); + if (!verdict.admit) { + expect(verdict.code).toBe(AUDIENCE_CONFIG_ERROR); + expect(verdict.message).toContain("'inviteOnly'"); + expect(verdict.message).toContain('configuration error'); + } + }); + + it('the three refusal codes are DISTINCT and the two new ones are registered in the ADR-0112 ledger', () => { + expect(SELF_REGISTRATION_CLOSED).not.toBe(EMAIL_DOMAIN_NOT_ALLOWED); + expect(SELF_REGISTRATION_CLOSED).not.toBe(AUDIENCE_CONFIG_ERROR); + const registered = (ERROR_CODE_LEDGER as Record)['@objectstack/plugin-auth']; + expect(registered).toContain(SELF_REGISTRATION_CLOSED); + expect(registered).toContain(EMAIL_DOMAIN_NOT_ALLOWED); + expect(registered).toContain(AUDIENCE_CONFIG_ERROR); + }); +}); + +describe('resolveAudience (#11739)', () => { + it('undeclared ⇒ invite_only (the ruled default — no legacy limbo)', () => { + expect(resolveAudience(undefined).posture).toBe('invite_only'); + expect(resolveAudience({}).posture).toBe('invite_only'); + }); + + it('marks an off-vocabulary posture invalid and coerces DISPLAY to the safe end, never the open one', () => { + const resolved = resolveAudience({ posture: 'inviteOnly' } as any); + expect(resolved.posture).toBe('invite_only'); + expect(resolved.invalid?.raw).toBe("'inviteOnly'"); + }); +}); + +describe('entry validation: assertAudienceConfig (#11739)', () => { + it('accepts an undeclared audience and every well-formed declaration', () => { + expect(() => assertAudienceConfig(undefined, undefined)).not.toThrow(); + expect(() => assertAudienceConfig({ posture: 'invite_only' }, undefined)).not.toThrow(); + expect(() => + assertAudienceConfig( + { posture: 'email_domain', allowedEmailDomains: ['acme.com'], selfRegistrationPermissionSet: 'portal_user' }, + undefined, + ), + ).not.toThrow(); + expect(() => + assertAudienceConfig({ posture: 'open', selfRegistrationPermissionSet: 'member_default' }, undefined), + ).not.toThrow(); + }); + + it('refuses an off-vocabulary posture loudly, naming the value and the vocabulary', () => { + expect(() => assertAudienceConfig({ posture: 'inviteOnly' } as any, undefined)).toThrow( + /'inviteOnly'.*invite_only, email_domain, open/s, + ); + }); + + it('refuses email_domain without a non-empty, well-formed, duplicate-free domain list', () => { + expect(() => + assertAudienceConfig({ posture: 'email_domain', selfRegistrationPermissionSet: 'p' }, undefined), + ).toThrow(/non-empty allowedEmailDomains/); + expect(() => + assertAudienceConfig( + { posture: 'email_domain', allowedEmailDomains: [], selfRegistrationPermissionSet: 'p' }, + undefined, + ), + ).toThrow(/non-empty allowedEmailDomains/); + expect(() => + assertAudienceConfig( + { posture: 'email_domain', allowedEmailDomains: ['@acme.com'], selfRegistrationPermissionSet: 'p' }, + undefined, + ), + ).toThrow(/not a bare domain name/); + expect(() => + assertAudienceConfig( + { posture: 'email_domain', allowedEmailDomains: ['acme.com', 'ACME.COM'], selfRegistrationPermissionSet: 'p' }, + undefined, + ), + ).toThrow(/duplicated/); + }); + + it('refuses inert declarations (ADR-0078): domains outside email_domain, permission set under invite_only', () => { + expect(() => + assertAudienceConfig({ posture: 'invite_only', allowedEmailDomains: ['acme.com'] }, undefined), + ).toThrow(/inert/); + expect(() => + assertAudienceConfig( + { posture: 'open', allowedEmailDomains: ['acme.com'], selfRegistrationPermissionSet: 'p' }, + undefined, + ), + ).toThrow(/inert/); + expect(() => + assertAudienceConfig({ posture: 'invite_only', selfRegistrationPermissionSet: 'p' }, undefined), + ).toThrow(/inert/); + }); + + it('requires the self-registrant permission set for self-registration-permitting postures, and refuses admin_full_access', () => { + expect(() => assertAudienceConfig({ posture: 'open' }, undefined)).toThrow(/selfRegistrationPermissionSet/); + expect(() => + assertAudienceConfig({ posture: 'open', selfRegistrationPermissionSet: 'admin_full_access' }, undefined), + ).toThrow(/admin_full_access/); + }); + + it('refuses the open-posture-with-verification-off contradiction (verification is FORCED on)', () => { + expect(() => + assertAudienceConfig( + { posture: 'open', selfRegistrationPermissionSet: 'p' }, + { requireEmailVerification: false }, + ), + ).toThrow(/verification/i); + // Explicit true and undefined are both fine — the wiring forces true. + expect(() => + assertAudienceConfig( + { posture: 'open', selfRegistrationPermissionSet: 'p' }, + { requireEmailVerification: true }, + ), + ).not.toThrow(); + }); + + it('the constructor runs the same assertion (boot refusal, not a first-signup 403)', () => { + expect(() => makeManager(createMemoryEngine(), { audience: { posture: 'bogus' } })).toThrow(/bogus/); + expect(() => + makeManager(createMemoryEngine(), { audience: { posture: 'email_domain', allowedEmailDomains: [] as string[] } }), + ).toThrow(/allowedEmailDomains/); + }); + + it('applyConfigPatch refuses an invalid merged result and the standing config keeps ruling', () => { + const manager = makeManager(createMemoryEngine(), { + audience: { posture: 'open', selfRegistrationPermissionSet: 'portal_user' }, + }); + expect(() => manager.applyConfigPatch({ audience: { posture: 'email_domain' } } as any)).toThrow( + /allowedEmailDomains/, + ); + expect(manager.getAudience().posture).toBe('open'); + // A verification-off patch beside a standing open posture is the same contradiction. + expect(() => + manager.applyConfigPatch({ emailAndPassword: { requireEmailVerification: false } } as any), + ).toThrow(/verification/i); + }); +}); + +describe('end of the chain: better-auth pipeline over the memory engine (#11739)', () => { + it('bootstrap: the very first signup is admitted under the default (undeclared ⇒ invite_only)', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + const res = await signUp(manager, 'first@anything.com'); + expect(res.status).toBeLessThan(300); + expect(engine.tables.get('sys_user')?.length).toBe(1); + }); + + it('undeclared audience: a SECOND self-serve signup is refused 403 SELF_REGISTRATION_CLOSED', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + await signUp(manager, 'first@anything.com'); + const res = await signUp(manager, 'second@anything.com'); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.code).toBe(SELF_REGISTRATION_CLOSED); + expect(String(body.message)).toContain('Self-registration is closed'); + // The refusal really refused — no second user row landed. + expect(engine.tables.get('sys_user')?.length).toBe(1); + }); + + it('invite_only: a pending invitation admits the invitee (case-insensitively), an expired one does not', async () => { + const engine = createMemoryEngine(); + seedExistingUser(engine); + const manager = makeManager(engine); + seedPendingInvitation(engine, 'Bob@Acme.com'); + const admitted = await signUp(manager, 'bob@acme.com'); + expect(admitted.status).toBeLessThan(300); + + seedPendingInvitation(engine, 'late@acme.com', { expires_at: new Date(Date.now() - 60_000) }); + const refused = await signUp(manager, 'late@acme.com'); + expect(refused.status).toBe(403); + expect((await refused.json()).code).toBe(SELF_REGISTRATION_CLOSED); + }); + + it('email_domain: on-list admitted AND the declared permission set really lands; off-list refused with EMAIL_DOMAIN_NOT_ALLOWED', async () => { + const engine = createMemoryEngine(); + seedExistingUser(engine); + seedPermissionSet(engine, 'portal_user'); + const manager = makeManager(engine, { + audience: { + posture: 'email_domain', + allowedEmailDomains: ['acme.com'], + selfRegistrationPermissionSet: 'portal_user', + }, + }); + + const admitted = await signUp(manager, 'User+tag@ACME.com'); + expect(admitted.status).toBeLessThan(300); + // declared = enforced: the grant row exists, bound to the created user. + await vi.waitFor(() => { + const grants = engine.tables.get('sys_user_permission_set') ?? []; + expect(grants.length).toBe(1); + expect(grants[0].permission_set_id).toBe('ps_portal_user'); + const created = (engine.tables.get('sys_user') ?? []).find((u: any) => u.email === 'user+tag@acme.com'); + expect(grants[0].user_id).toBe(created?.id); + }); + + const refused = await signUp(manager, 'user@mail.acme.com'); // subdomain: NOT implied + expect(refused.status).toBe(403); + const body = await refused.json(); + expect(body.code).toBe(EMAIL_DOMAIN_NOT_ALLOWED); + expect((engine.tables.get('sys_user') ?? []).some((u: any) => u.email === 'user@mail.acme.com')).toBe(false); + }); + + it('email_domain forces email verification on: the wired flag, the public config, and the minted session agree', async () => { + const engine = createMemoryEngine(); + seedExistingUser(engine); + seedPermissionSet(engine, 'portal_user'); + const manager = makeManager(engine, { + audience: { + posture: 'email_domain', + allowedEmailDomains: ['acme.com'], + selfRegistrationPermissionSet: 'portal_user', + }, + }); + // Public surface mirrors the forcing (nothing set requireEmailVerification). + const pub = manager.getPublicConfig(); + expect(pub.emailPassword.requireEmailVerification).toBe(true); + expect((pub.features as any).audiencePosture).toBe('email_domain'); + // And better-auth really runs with it: the admitted signup creates the + // user but does NOT auto-sign-in an unverified account. + const res = await signUp(manager, 'user@acme.com'); + expect(res.status).toBeLessThan(300); + const body = await res.json().catch(() => ({})); + expect(body?.token ?? null).toBeNull(); + }); + + it('a DANGLING declared permission set refuses admission with AUTH_CONFIG_ERROR — never an ungranted admit', async () => { + const engine = createMemoryEngine(); + seedExistingUser(engine); + // NOTE: no sys_permission_set row named 'ghost' is seeded. + const manager = makeManager(engine, { + audience: { posture: 'open', selfRegistrationPermissionSet: 'ghost' }, + }); + const res = await signUp(manager, 'user@anywhere.com'); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.code).toBe(AUDIENCE_CONFIG_ERROR); + expect(String(body.message)).toContain("'ghost'"); + expect((engine.tables.get('sys_user') ?? []).length).toBe(1); + }); + + it('open: self-serve admitted and granted', async () => { + const engine = createMemoryEngine(); + seedExistingUser(engine); + seedPermissionSet(engine, 'member_default'); + const manager = makeManager(engine, { + audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' }, + }); + const res = await signUp(manager, 'anyone@anywhere.com'); + expect(res.status).toBeLessThan(300); + await vi.waitFor(() => { + expect((engine.tables.get('sys_user_permission_set') ?? []).length).toBe(1); + }); + }); + + it('an off-vocabulary posture smuggled past entry (direct mutation) fails CLOSED at admission with AUTH_CONFIG_ERROR', async () => { + const engine = createMemoryEngine(); + seedExistingUser(engine); + const manager = makeManager(engine); + (manager as any).config.audience = { posture: 'inviteOnly' }; + const res = await signUp(manager, 'user@anywhere.com'); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.code).toBe(AUDIENCE_CONFIG_ERROR); + // Display surfaces coerce to the SAFE end while enforcement refuses. + expect((manager.getPublicConfig().features as any).audiencePosture).toBe('invite_only'); + }); + + it('the admission gate classifies enterprise oidcProviders JIT as provider class (admitted under invite_only)', async () => { + const engine = createMemoryEngine(); + seedExistingUser(engine); + const manager = makeManager(engine, { + oidcProviders: [ + { providerId: 'okta', clientId: 'x', clientSecret: 'y', discoveryUrl: 'https://idp.example/.well-known/openid-configuration' }, + ], + }); + const nonBootstrapCtx = { context: { adapter: { findOne: async () => ({ id: 'usr_existing' }) } } }; + const enterprise = await (manager as any).validateAudienceAdmission( + { + user: { email: 'jit@corp.com' }, + source: { action: 'create-user', method: 'oauth', oauth: { providerId: 'okta' } }, + }, + nonBootstrapCtx, + ); + expect(enterprise).toBeUndefined(); + const social = await (manager as any).validateAudienceAdmission( + { + user: { email: 'jit@gmail.com' }, + source: { action: 'create-user', method: 'oauth', oauth: { providerId: 'google' } }, + }, + nonBootstrapCtx, + ); + expect(social?.error).toBe(SELF_REGISTRATION_CLOSED); + // SSO JIT and SCIM ride their own methods — admitted under invite_only. + for (const method of ['sso-oidc', 'sso-saml', 'scim', 'admin']) { + const verdict = await (manager as any).validateAudienceAdmission( + { user: { email: 'x@corp.com' }, source: { action: 'create-user', method } }, + nonBootstrapCtx, + ); + expect(verdict).toBeUndefined(); + } + // link-account / sign-in actions are not audience admission. + const linking = await (manager as any).validateAudienceAdmission( + { user: { email: 'x@corp.com' }, source: { action: 'link-account', method: 'oauth', oauth: { providerId: 'google' } } }, + nonBootstrapCtx, + ); + expect(linking).toBeUndefined(); + }); +}); diff --git a/packages/plugins/plugin-auth/src/audience-posture.ts b/packages/plugins/plugin-auth/src/audience-posture.ts new file mode 100644 index 0000000000..159356aee4 --- /dev/null +++ b/packages/plugins/plugin-auth/src/audience-posture.ts @@ -0,0 +1,433 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Audience posture — the SINGLE owner of "who may become a user of this + * environment" (#11739 / epic #11723; vocabulary and completeness predicates + * declared in `@objectstack/spec/system` `AudienceConfigSchema`). + * + * ## The one enforcement point + * + * Enforcement rides better-auth 1.7.1's `user.validateUserInfo` gate — the + * vendor's own admission seam, invoked by `internalAdapter.createUser` for + * EVERY creation path (measured against the installed dist: sign-up.mjs passes + * `{method:'email-password'}`, link-account.mjs `{method:'oauth'|'sso-*'}`, + * admin routes `{method:'admin'}`, @better-auth/scim `{method:'scim'}`, + * magic-link / email-otp / phone-number / anonymous each their own). A + * rejection surfaces as a 403 `APIError` whose `code` is OURS, and browser + * OAuth flows redirect to the error URL carrying the same code. The gate + * fails CLOSED by vendor construction (a throwing hook rejects provisioning). + * This module owns the DECISION (`decideAudienceAdmission`, a pure function + * the test matrix drives); auth-manager owns the wiring and the I/O + * (bootstrap probe, invitation lookup, permission-set resolution). + * + * ## What is gated, and what deliberately is not + * + * The posture governs SELF-registration — a person becoming a user by their + * own act. Three creation classes ({@link classifyCreationMethod}): + * + * - `self-serve` (POSTURE-GATED): email/password sign-up, social-provider + * OAuth JIT (`socialProviders` — a public IdP account is not an operator + * act), magic-link, email-otp, phone-number, anonymous, siwe, and ANY + * unrecognized method (fail closed: a future plugin's creation path is + * gated until deliberately classified). + * - `operator` (never gated): `admin` (create-user / bulk import) and `scim` + * (IdP-driven provisioning) — these ARE the invite/provisioning mechanisms + * the closed postures point operators at. + * - `provider` (never gated): JIT provisioning through an OPERATOR-REGISTERED + * identity authority — `sso-oidc` / `sso-saml` (@better-auth/sso providers; + * registration is platform-admin-only, #10009) and `oauth` whose providerId + * is a configured `oidcProviders` entry (enterprise SSO incl. the cloud + * platform IdP, whose OP side additionally enforces app-assignment, D5.1). + * Registering an IdP is the operator declaring "this directory is my + * audience"; and the closed vocabulary could not express the standard + * enterprise combo (self-registration closed + IdP JIT on) any other way — + * gating these under `invite_only` would hard-brick every SSO-enforced + * deployment whose entire user base arrives via the IdP. + * + * Only `action: 'create-user'` is judged: `link-account` and provider + * `sign-in` concern an EXISTING user's identity, not audience admission. + * + * ## The invitation carve-out + * + * `invite_only` means BY INVITATION — not "no new users". better-auth's + * organization invitation flow requires the invitee to hold an account before + * `accept-invitation`, and for a brand-new invitee that account comes from the + * self-serve sign-up route. So a self-serve creation whose email holds a + * PENDING, unexpired `sys_invitation` row is admitted under every posture + * (under `email_domain` an explicit invitation also trumps the domain list — + * an operator inviting an external contractor must not dead-end them). + * Without this carve-out the posture would be "admin-create only" and the + * invitation surface would be dead for new users — the invite dead-end class. + * + * ## Pinned domain-matching rules (`email_domain`) + * + * An unpinned matcher is where this class of gate leaks, so the rules are + * stated and test-locked ({@link emailDomainAllowed}): + * + * 1. The candidate domain is everything after the LAST `@` of the address; + * an address with no `@`, or an empty remainder, has no domain and never + * matches (placeholder addresses for phone-only users land here). + * 2. Comparison is case-insensitive on both sides. + * 3. A list entry matches by EXACT equality — subdomains are NOT implied + * (`user@mail.acme.com` is not admitted by `acme.com`; declare + * `mail.acme.com` too) and wildcards are refused at declaration. + * 4. `+tag` local-part suffixes are irrelevant — matching never reads the + * local part, so `user+anything@acme.com` matches like `user@acme.com`. + * 5. No punycode/IDN normalization is applied: the declared entry and the + * address's domain are compared as lowercased strings. Declare an + * internationalized domain in the exact form addresses carry it. + * + * ## Fail postures, mirrored from the MembershipPolicy precedent (#5205) + * + * An off-vocabulary posture at admission time is REFUSED with + * `AUTH_CONFIG_ERROR` — a verdict distinct from `SELF_REGISTRATION_CLOSED` + * ("a valid posture said no") because sending the debugger to re-read a + * setting that looks exactly as they left it is the recorded failure mode. A + * declared permission set that cannot be resolved refuses admission the same + * way: admitting a self-registrant WITHOUT the declared grant would be the + * ADR-0078 "declared but inert" defect at runtime, and fail-open is the one + * direction this gate must never take. + */ + +import { + AUDIENCE_POSTURES, + SystemUserId, + isAudiencePosture, + audiencePermitsSelfRegistration, + type AudienceConfig, + type AudiencePosture, + type EmailAndPasswordConfig, +} from '@objectstack/spec/system'; + +/** Wire code for "a valid posture closed self-registration" (ADR-0112 ledger). */ +export const SELF_REGISTRATION_CLOSED = 'SELF_REGISTRATION_CLOSED'; +/** Wire code for "the address's domain is off the email_domain allowlist" (ADR-0112 ledger). */ +export const EMAIL_DOMAIN_NOT_ALLOWED = 'EMAIL_DOMAIN_NOT_ALLOWED'; +/** Wire code for "the audience configuration itself is unusable" (registered pre-existing). */ +export const AUDIENCE_CONFIG_ERROR = 'AUTH_CONFIG_ERROR'; + +/** The audience declaration as the live config resolves it. */ +export interface ResolvedAudience { + posture: AudiencePosture; + allowedEmailDomains: readonly string[]; + selfRegistrationPermissionSet?: string; + /** + * Present when the RAW posture was off-vocabulary (reachable only by + * mutating config past the entry validation). `posture` is then coerced to + * `invite_only` for DISPLAY surfaces; the admission path refuses with + * `AUTH_CONFIG_ERROR` instead of trusting the coercion (fail closed, and + * the refusal names the offending value — never "policy said no"). + */ + invalid?: { raw: string }; +} + +/** Bounded, type-safe description of a rejected value (the #5205 pattern). */ +export function describeAudiencePosture(value: unknown): string { + if (typeof value === 'string') { + return value.length > 64 ? `'${value.slice(0, 64)}…' (truncated)` : `'${value}'`; + } + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + return `[${typeof value}]`; +} + +/** + * Resolve the raw authored `audience` config to the shape every consumer + * reads. Undeclared ⇒ `invite_only` (maintainer ruling 2026-08-24, epic + * #11723 — no legacy/undeclared limbo). Never throws: entry refusal is + * {@link assertAudienceConfig}'s job; this only marks an off-vocabulary + * posture `invalid` so consumers fail closed rather than fail open. + */ +export function resolveAudience(raw: AudienceConfig | undefined): ResolvedAudience { + const rawPosture = (raw as { posture?: unknown } | undefined)?.posture; + const domains = Array.isArray(raw?.allowedEmailDomains) ? raw.allowedEmailDomains : []; + const set = + typeof raw?.selfRegistrationPermissionSet === 'string' && raw.selfRegistrationPermissionSet + ? raw.selfRegistrationPermissionSet + : undefined; + if (rawPosture === undefined || rawPosture === null) { + return { posture: 'invite_only', allowedEmailDomains: domains, selfRegistrationPermissionSet: set }; + } + if (!isAudiencePosture(rawPosture)) { + return { + posture: 'invite_only', + allowedEmailDomains: domains, + selfRegistrationPermissionSet: set, + invalid: { raw: describeAudiencePosture(rawPosture) }, + }; + } + return { posture: rawPosture, allowedEmailDomains: domains, selfRegistrationPermissionSet: set }; +} + +/** + * Entry validation — REFUSES an unusable audience declaration at the point it + * enters the manager (constructor and `applyConfigPatch`), loudly, with the + * remedy in the message. Mirrors `AudienceConfigSchema`'s predicates (the + * schema guards the authoring surface; the runtime receives plain objects) and + * adds the one cross-field invariant the schema cannot see: + * + * posture permits self-registration ⇒ `emailAndPassword.requireEmailVerification` + * must not be explicitly `false` (verification is FORCED on by the wiring; + * an explicit contradiction is a config that opens self-registration while + * disabling verification — refused, an unverified allowlisted-domain signup + * is colleague impersonation and makes the domain gate decorative). + */ +export function assertAudienceConfig( + raw: AudienceConfig | undefined, + emailAndPassword: EmailAndPasswordConfig | undefined, +): void { + if (raw === undefined || raw === null) return; + const fail = (message: string): never => { + throw new Error(`[audience] invalid audience configuration: ${message}`); + }; + const rawPosture = (raw as { posture?: unknown }).posture; + if (rawPosture !== undefined && !isAudiencePosture(rawPosture)) { + fail( + `posture ${describeAudiencePosture(rawPosture)} is not a recognized audience posture — ` + + `expected one of: ${AUDIENCE_POSTURES.join(', ')}. Refused, never coerced (#5205 fail-open precedent).`, + ); + } + const posture: AudiencePosture = (rawPosture as AudiencePosture | undefined) ?? 'invite_only'; + const domains = raw.allowedEmailDomains; + if (domains !== undefined && !Array.isArray(domains)) { + fail('allowedEmailDomains must be an array of bare domain names'); + } + if (posture === 'email_domain') { + if (!domains || domains.length === 0) { + fail( + "posture 'email_domain' requires a non-empty allowedEmailDomains list — declare the domains, " + + "or use posture 'invite_only'.", + ); + } + } else if (domains !== undefined) { + fail( + `allowedEmailDomains is only read under posture 'email_domain' — under '${posture}' it is declared ` + + 'but inert (ADR-0078) and reads as a wall that does not exist. Remove it, or set posture to email_domain.', + ); + } + if (domains) { + const seen = new Set(); + for (const entry of domains) { + if (typeof entry !== 'string' || !isDeclarableEmailDomain(entry)) { + fail( + `allowedEmailDomains entry ${describeAudiencePosture(entry)} is not a bare domain name ` + + '(expected e.g. "acme.com" — no scheme, no "@", no wildcard, at least one dot).', + ); + } + const lowered = entry.toLowerCase(); + if (seen.has(lowered)) fail(`allowedEmailDomains entry '${entry}' is duplicated (matching is case-insensitive)`); + seen.add(lowered); + } + } + const set = raw.selfRegistrationPermissionSet; + if (audiencePermitsSelfRegistration(posture)) { + if (typeof set !== 'string' || set.length === 0) { + fail( + `posture '${posture}' permits self-registration, so selfRegistrationPermissionSet must DECLARE the ` + + 'permission set a self-registrant receives (the implicit member_default fallback is retired; ' + + 'declaring member_default explicitly is allowed).', + ); + } + if (set === 'admin_full_access') { + fail( + "selfRegistrationPermissionSet must not be 'admin_full_access' — a platform-admin grant to every " + + 'self-registrant is never a declarable audience.', + ); + } + if (emailAndPassword?.requireEmailVerification === false) { + fail( + `posture '${posture}' opens self-registration, which FORCES email verification on — ` + + 'emailAndPassword.requireEmailVerification: false contradicts it and is refused ' + + '(an unverified allowlisted-domain signup is colleague impersonation). ' + + "Remove the explicit false, or close the posture to 'invite_only'.", + ); + } + } else if (set !== undefined) { + fail( + "selfRegistrationPermissionSet is only read under a posture that permits self-registration — under " + + `'${posture}' it is declared but inert (ADR-0078). Remove it, or open the posture deliberately.`, + ); + } +} + +/** Declaration-side domain shape (mirrors the spec schema's regex). */ +const DECLARABLE_DOMAIN = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/; + +export function isDeclarableEmailDomain(entry: string): boolean { + return DECLARABLE_DOMAIN.test(entry); +} + +/** + * Pinned rule 1: the candidate domain is everything after the LAST `@`, + * lowercased; no `@` / empty remainder ⇒ `null` (never matches). + */ +export function extractEmailDomain(email: unknown): string | null { + if (typeof email !== 'string') return null; + const at = email.lastIndexOf('@'); + if (at < 0) return null; + const domain = email.slice(at + 1).trim().toLowerCase(); + return domain.length > 0 ? domain : null; +} + +/** Pinned rules 2–5: exact, case-insensitive, per-entry equality. */ +export function emailDomainAllowed(email: unknown, allowedDomains: readonly string[]): boolean { + const domain = extractEmailDomain(email); + if (!domain) return false; + for (const entry of allowedDomains) { + if (typeof entry === 'string' && entry.toLowerCase() === domain) return true; + } + return false; +} + +/** + * THE bootstrap population predicate — "does this environment already have a + * HUMAN user?" — owned once, here, and asked by every consumer of the + * first-run bypass. + * + * ## Humans, not rows (the canonical answer) + * + * The bootstrap bypass counts **non-system humans**, never "any `sys_user` + * row". Three call sites ask this same question and they must agree: + * + * - the audience gate's bootstrap bypass (`isBootstrapCreation`); + * - plugin-auth's dev-admin seed, whose own precondition has always filtered + * `usr_system` / `role === 'system'` before deciding to seed; + * - plugin-security's first-user detection, which prints "no human users yet + * — first sign-up will be promoted to platform admin" off the identical + * filter and then promotes that sign-up. + * + * If the audience gate counted ANY row while those two counted humans, the + * two would disagree on exactly one population: a database still carrying the + * legacy `usr_system` service row (`SystemUserId.SYSTEM` — no longer + * provisioned, but present in every DB an older runtime created). There, + * plugin-security would announce "no human users yet" and stand ready to + * promote the first sign-up while the audience gate refused that very + * sign-up with `SELF_REGISTRATION_CLOSED` — a fresh-looking install locked + * out of itself, which is the one outcome the bypass exists to prevent. + * Humans is therefore canonical, and the disagreement is closed by both + * plugin-auth call sites reading THIS function rather than re-spelling the + * filter. + * + * ## Why the filter runs in JS and not in the where-clause + * + * A store-side `role != 'system'` drops rows whose `role` is NULL under SQL + * three-valued logic — i.e. it would hide ordinary humans, the direction that + * fails open. The rows are read unfiltered and judged here. + */ +export function isHumanUserRow(row: unknown): boolean { + if (!row || typeof row !== 'object') return false; + const user = row as { id?: unknown; role?: unknown }; + if (user.id === SystemUserId.SYSTEM) return false; + if (user.role === 'system') return false; + return true; +} + +/** + * How the incoming creation reached the platform, from the vendor-supplied + * `source` (see the module doc for why each class lands where it does). + */ +export type AudienceCreationClass = 'operator' | 'provider' | 'self-serve'; + +const OPERATOR_METHODS: ReadonlySet = new Set(['admin', 'scim']); +const PROVIDER_METHODS: ReadonlySet = new Set(['sso-oidc', 'sso-saml']); + +export function classifyCreationMethod( + source: { method?: unknown; oauth?: { providerId?: unknown } } | undefined, + opts: { enterpriseOAuthProviderIds: ReadonlySet }, +): AudienceCreationClass { + const method = typeof source?.method === 'string' ? source.method : ''; + if (OPERATOR_METHODS.has(method)) return 'operator'; + if (PROVIDER_METHODS.has(method)) return 'provider'; + if (method === 'oauth') { + const providerId = source?.oauth?.providerId; + if (typeof providerId === 'string' && opts.enterpriseOAuthProviderIds.has(providerId)) { + return 'provider'; + } + return 'self-serve'; + } + // Known self-serve methods AND any unrecognized method: posture-gated + // (fail closed — an unclassified future creation path must not bypass the + // audience wall silently). + return 'self-serve'; +} + +export type AudienceAdmission = + | { admit: true; grantPermissionSet: boolean } + | { admit: false; code: string; message: string }; + +export interface AudienceAdmissionInput { + audience: ResolvedAudience; + creationClass: AudienceCreationClass; + /** The would-be user's email, as the creation path carries it. */ + email: string | undefined; + /** A pending, unexpired `sys_invitation` row exists for this email. */ + hasPendingInvitation: boolean; + /** + * No user exists yet — the first-run owner wizard / seeded admin creating + * the very first account. Mirrors the `disableSignUp` bootstrap bypass: a + * fresh install must never lock its operator out. + */ + isBootstrap: boolean; +} + +/** + * THE admission decision — pure, so the posture × method matrix is a table of + * direct calls. Order: creation-class exemptions → bootstrap → vocabulary + * guard (fail closed) → posture semantics. + */ +export function decideAudienceAdmission(input: AudienceAdmissionInput): AudienceAdmission { + const { audience, creationClass, email, hasPendingInvitation, isBootstrap } = input; + if (creationClass === 'operator' || creationClass === 'provider') { + return { admit: true, grantPermissionSet: false }; + } + if (isBootstrap) { + return { admit: true, grantPermissionSet: false }; + } + if (audience.invalid) { + return { + admit: false, + code: AUDIENCE_CONFIG_ERROR, + message: + `audience posture ${audience.invalid.raw} is not a recognized value (expected one of: ` + + `${AUDIENCE_POSTURES.join(', ')}) — self-registration is refused until the configuration is fixed. ` + + 'This is a configuration error, not a policy refusal.', + }; + } + if (hasPendingInvitation) { + // An explicit invitation is a stronger operator act than any posture — + // it admits the invitee's own account creation under invite_only AND + // trumps the email_domain allowlist (module doc: the invite dead-end). + return { admit: true, grantPermissionSet: false }; + } + switch (audience.posture) { + case 'invite_only': + return { + admit: false, + code: SELF_REGISTRATION_CLOSED, + message: + 'Self-registration is closed on this environment (audience posture invite_only). ' + + 'Ask an administrator for an invitation.', + }; + case 'email_domain': + if (emailDomainAllowed(email, audience.allowedEmailDomains)) { + return { admit: true, grantPermissionSet: true }; + } + return { + admit: false, + code: EMAIL_DOMAIN_NOT_ALLOWED, + message: + 'Self-registration on this environment is limited to approved email domains, and this address ' + + 'is not on the list. Use your organization email, or ask an administrator for an invitation.', + }; + case 'open': + return { admit: true, grantPermissionSet: true }; + default: { + // Unreachable for a well-typed posture; keep the fail-closed floor. + return { + admit: false, + code: AUDIENCE_CONFIG_ERROR, + message: `audience posture ${describeAudiencePosture(audience.posture)} is not a recognized value.`, + }; + } + } +} diff --git a/packages/plugins/plugin-auth/src/auth-manager.optional-plugin-isolation.test.ts b/packages/plugins/plugin-auth/src/auth-manager.optional-plugin-isolation.test.ts index 1f2f533fd1..4485279683 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.optional-plugin-isolation.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.optional-plugin-isolation.test.ts @@ -15,6 +15,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AuthManager } from './auth-manager'; +import { inviteForAudienceGate } from './audience-gate-test-support'; const failures = vi.hoisted(() => ({ oauthProvider: false, bearer: false })); @@ -125,8 +126,13 @@ describe('AuthManager – optional better-auth plugin isolation', () => { plugins: { oidcProvider: true }, }); - const signUp = (manager: AuthManager, email: string) => - manager.handleRequest( + const signUp = async (manager: AuthManager, email: string) => { + // [#11739] default posture invite_only: fixture users beyond the first + // enter through the invitation carve-out (see audience-gate-test-support). + // This harness's engine hides its tables, so the seed goes through + // engine.insert and MUST be awaited. + await inviteForAudienceGate(manager, email); + return manager.handleRequest( new Request('http://localhost:3000/api/v1/auth/sign-up/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -137,6 +143,7 @@ describe('AuthManager – optional better-auth plugin isolation', () => { }), }), ); + }; beforeEach(() => { failures.oauthProvider = false; diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index abd5fbc6f6..254e5feb2e 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -2502,6 +2502,9 @@ describe('AuthManager', () => { // A vanilla deployment (no tenancy service wired, no env override) is // `single`, matching `multiOrgEnabled: false`. tenancyPosture: 'single', + // [#11739] Which audience posture is in force. Undeclared ⇒ the safe + // default, `invite_only` — no legacy limbo. + audiencePosture: 'invite_only', degradedTenancy: false, privacyUrl: 'https://objectstack.ai/privacy', termsUrl: 'https://objectstack.ai/terms', diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index bc1825a4f5..aaaf7ba9ee 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -10,6 +10,16 @@ import type { AuthPluginConfig, OidcProvidersConfig, } from '@objectstack/spec/system'; +import { SystemObjectName, audiencePermitsSelfRegistration } from '@objectstack/spec/system'; +import { + assertAudienceConfig, + classifyCreationMethod, + decideAudienceAdmission, + isHumanUserRow, + resolveAudience, + AUDIENCE_CONFIG_ERROR, + type ResolvedAudience, +} from './audience-posture.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 @@ -1008,6 +1018,15 @@ export class AuthManager { // history retention follows the cooldown; see otp-send-guard.ts. assertOtpCooldownSeconds(config.phoneOtp?.cooldownSeconds); + // [#11739] Audience posture — ENTRY validation, at boot, for the same + // reason as the OTP guard above: an unusable audience declaration must + // refuse the boot loudly, not surface as a 403 on the first sign-up. + // Off-vocabulary postures, inert declarations (ADR-0078) and the + // open-posture-with-verification-off contradiction are all refused here; + // `applyConfigPatch` runs the same assertion on the merged result so no + // entry path can smuggle an invalid declaration past boot. + assertAudienceConfig(config.audience, config.emailAndPassword); + // WebContainer (StackBlitz) compatibility — install a synchronous // AsyncLocalStorage polyfill for better-auth's request-state global // BEFORE better-auth ever instantiates its own. See the helper for the @@ -1149,6 +1168,16 @@ export class AuthManager { changeEmail: { enabled: true, }, + + // ── [#11739] Audience posture — THE enforcement point ────────────── + // better-auth's own admission gate, invoked by internalAdapter + // .createUser for EVERY creation path with a method-discriminated + // `source` (email-password, oauth, sso-oidc/saml, magic-link, + // email-otp, phone-number, anonymous, admin, scim, …), failing CLOSED + // by vendor construction. One seam, one owner — see + // `audience-posture.ts` for the decision semantics, the pinned + // domain-matching rules, and the creation-class table. + validateUserInfo: (data: any, ctx: any) => this.validateAudienceAdmission(data, ctx), }, account: { ...AUTH_ACCOUNT_CONFIG, @@ -1210,8 +1239,18 @@ export class AuthManager { ...(passwordHasher ? { password: passwordHasher } : {}), ...(effectiveDisableSignUp != null ? { disableSignUp: effectiveDisableSignUp } : {}), - ...(this.config.emailAndPassword?.requireEmailVerification != null - ? { requireEmailVerification: this.config.emailAndPassword.requireEmailVerification } : {}), + // [#11739] Invariant: a posture that permits self-registration + // (email_domain / open) FORCES email verification on — an + // unverified allowlisted-domain signup is colleague impersonation + // and makes the domain gate decorative. The explicit-false + // contradiction was already refused at config entry + // (assertAudienceConfig), so this forcing never overrides a value + // the entry validation accepted; getPublicConfig() mirrors it so + // the advertised flag cannot disagree with the wired one. + ...(audiencePermitsSelfRegistration(this.getAudience().posture) + ? { requireEmailVerification: true } + : (this.config.emailAndPassword?.requireEmailVerification != null + ? { requireEmailVerification: this.config.emailAndPassword.requireEmailVerification } : {})), ...(this.config.emailAndPassword?.minPasswordLength != null ? { minPasswordLength: this.config.emailAndPassword.minPasswordLength } : {}), ...(this.config.emailAndPassword?.maxPasswordLength != null @@ -1720,6 +1759,46 @@ export class AuthManager { // fall through to better-auth's own handler } + // ── [#11739] Audience posture on the PASSWORD sign-up route ── + // The DECISION is validateAudienceAdmission — the same single owner + // the user.validateUserInfo gate calls for every creation path. + // This route alone needs the refusal raised HERE, before the + // handler: better-auth's sign-up route wraps createUser in an + // anti-enumeration shield (dist/api/routes/sign-up.mjs:163,235 — + // `shouldReturnGenericDuplicateResponse`, on whenever + // requireEmailVerification is on) that converts ANY 403 from the + // creation seam into a synthetic 200 "success". Correct for + // account-existence oracles; wrong for an audience refusal, which + // is deterministic per (posture, domain, invitation) and reveals + // nothing about any account — swallowed, it would turn the domain + // gate's refusal into a silent black hole (fake success, no + // verification mail ever). Measured: with posture `open`/ + // `email_domain` (verification forced on) the validateUserInfo + // refusal came back 200 `{token:null,user:{synthetic}}`. The + // validateUserInfo gate stays wired for this route too — the + // decision is idempotent, so the second ask cannot disagree. + if (ctx?.path === '/sign-up/email') { + const refusal = await this.validateAudienceAdmission( + { + user: { + ...(typeof ctx?.body?.email === 'string' + ? { email: ctx.body.email.toLowerCase() } + : {}), + }, + source: { action: 'create-user', method: 'email-password' }, + }, + ctx, + ); + if (refusal) { + const { APIError } = await import('better-auth/api'); + throw new APIError('FORBIDDEN', { + code: refusal.error, + message: refusal.errorDescription ?? refusal.error, + }); + } + // fall through — the vendor still decides everything it owns + } + // ── ADR-0069 D2: account lockout (gate) ───────────────────── // Reject a sign-in for a locked identity BEFORE better-auth checks // the password — a lock must hold even against the correct password. @@ -1732,15 +1811,16 @@ export class AuthManager { if (ctx?.path !== '/sign-up/email') return; const ep = ctx?.context?.options?.emailAndPassword; if (!ep?.disableSignUp) return; - try { - const adapter = ctx.context.adapter; - const existing = await adapter.findOne({ model: 'user', where: [] }); - if (!existing) { - ctx.context.__osDisableSignUpOrig = ep.disableSignUp; - ep.disableSignUp = false; - } - } catch { - // Adapter not ready → keep disableSignUp on. + // [#11767] Same population question, same owner. This site used to + // ask `ctx.context.adapter.findOne({ model: 'user', where: [] })`, + // which the real ObjectQL engine REFUSES (#4419 — + // `requireFindOnePredicate`) rather than answers; the surrounding + // `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()) { + ctx.context.__osDisableSignUpOrig = ep.disableSignUp; + ep.disableSignUp = false; } }), after: createAuthMiddleware(async (ctx: any) => { @@ -3256,12 +3336,40 @@ export class AuthManager { next.socialProviders = patch.socialProviders; } + // [#11739] The audience declaration replaces WHOLE (never a deep merge — + // a posture switch changes which sibling keys are even legal, so merging + // stale domains under a new posture would manufacture the inert shapes + // the entry validation refuses). Validate the MERGED result before it + // becomes current: a patch that would leave the audience configuration + // unusable — off-vocabulary posture, inert keys, verification + // contradiction — is refused loudly here and the standing config keeps + // ruling (the settings-boundary posture of #5152: reject, never coerce). + if ('audience' in patch) { + next.audience = patch.audience; + } + if ('audience' in patch || 'emailAndPassword' in patch) { + assertAudienceConfig(next.audience, next.emailAndPassword); + } + this.config = next; if (this.auth && !patch.authInstance) { this.auth = null; } } + /** + * [#11739] The deployment's audience posture **as it stands right now** — + * the `getMembershipPolicy()` pattern: the ONE source every consumer reads + * (the validateUserInfo admission gate, the better-auth wiring's forced + * email verification, and `getPublicConfig()`'s `features.audiencePosture`), + * so a config patch reaches all of them without a restart and no captured + * constructor option can go stale against the live setting. Undeclared ⇒ + * `invite_only` (epic #11723 ruling — no legacy/undeclared limbo). + */ + getAudience(): ResolvedAudience { + return resolveAudience(this.config.audience); + } + /** * ADR-0093 D1 — the deployment's membership policy **as it stands right now**. * @@ -3334,6 +3442,355 @@ export class AuthManager { } } + // ── [#11739] Audience posture — admission gate + self-registration grant ── + + /** + * Self-registration grants staged between the admission gate (which runs + * just before the user row is inserted and is the last point that may + * REFUSE) and `user.create.after` (the first point the created user id + * exists). Keyed by lowercased email — better-auth lowercases the address + * on `createUser`, and an in-flight duplicate email cannot create twice + * (unique). Entries are pruned by age so an admission whose creation never + * completed cannot leak. + */ + private pendingSelfRegistrationGrants = new Map< + string, + { setName: string; stagedAtMs: number } + >(); + + private static readonly SELF_REG_GRANT_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 + * question of the same window. + */ + private static readonly BOOTSTRAP_USER_PROBE_LIMIT = 50; + + /** `error` when the host logger carries it, else the guaranteed `warn` channel (#9754). */ + private audienceLogError(message: string, meta?: Record): void { + const logger = this.config.logger as + | { error?: (m: string, meta?: any) => void; warn?: (m: string, meta?: any) => void; info?: (m: string, meta?: any) => void } + | undefined; + (logger?.error ?? logger?.warn)?.(message, meta); + } + + /** OAuth providerIds that are OPERATOR-REGISTERED identity authorities (enterprise `oidcProviders`, incl. the cloud platform IdP). */ + private enterpriseOAuthProviderIds(): ReadonlySet { + const ids = new Set(); + for (const p of this.config.oidcProviders ?? []) { + const id = (p as { providerId?: unknown } | undefined)?.providerId; + if (typeof id === 'string' && id) ids.add(id); + } + return ids; + } + + /** + * The better-auth `user.validateUserInfo` gate — [#11739]'s ONE enforcement + * point. Semantics (creation classes, invitation carve-out, pinned domain + * matching, fail postures) live in `audience-posture.ts`; this method owns + * only the I/O the pure decision needs: the zero-user bootstrap probe, the + * pending-invitation lookup, and the declared permission set's completeness + * check. Answers `undefined` to admit, `{ error, errorDescription }` to + * refuse (the vendor surfaces it as a 403 whose `code` is ours; browser + * OAuth flows redirect to the error URL carrying the same code). + * + * [#11767] The vendor's second argument — the endpoint context — is + * deliberately UNREAD. Both probes below take their own ctx-independent data + * path, because sourcing this gate's I/O from the request context is exactly + * what made the bootstrap bypass inert (see {@link isBootstrapCreation}). + * The parameter is kept so the signature still reads as the vendor's. + */ + private async validateAudienceAdmission( + data: { + user?: Record; + source?: { action?: string; method?: string; oauth?: { providerId?: string } }; + }, + _ctx?: unknown, + ): Promise<{ error: string; errorDescription?: string } | undefined> { + try { + // link-account / provider sign-in concern an EXISTING user's identity, + // not audience admission — the posture judges only who may COME INTO + // 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; + let isBootstrap = false; + let hasPendingInvitation = false; + if (creationClass === 'self-serve') { + // Only the gated class pays for the probes. + isBootstrap = await this.isBootstrapCreation(); + if (!isBootstrap && email) hasPendingInvitation = await this.hasPendingInvitationFor(email); + } + const verdict = decideAudienceAdmission({ + audience, + creationClass, + email, + hasPendingInvitation, + isBootstrap, + }); + if (!verdict.admit) { + if (verdict.code === AUDIENCE_CONFIG_ERROR) { + // Config-unusable refusals are the operator's bug, not policy — + // report at error level with the offending value in the line + // (#5205: never let "your posture is not a posture" read as + // "policy said no"). + this.audienceLogError(`[audience] refusing self-registration: ${verdict.message}`, { + method: data?.source?.method, + }); + } else { + this.config.logger?.info?.('[audience] self-registration refused by posture', { + code: verdict.code, + method: data?.source?.method, + posture: audience.posture, + }); + } + return { error: verdict.code, errorDescription: verdict.message }; + } + if (verdict.grantPermissionSet) { + const setName = audience.selfRegistrationPermissionSet; + if (!setName) { + // Unreachable past entry validation; keep the fail-closed floor — + // admitting without the declared grant would be ADR-0078's + // "declared but inert" at runtime. + const message = + `audience posture '${audience.posture}' permits self-registration but declares no ` + + 'selfRegistrationPermissionSet — self-registration is refused until the configuration is fixed.'; + this.audienceLogError(`[audience] ${message}`); + return { error: AUDIENCE_CONFIG_ERROR, errorDescription: message }; + } + const resolvable = await this.selfRegistrationSetResolvable(setName); + if (!resolvable) { + const message = + `the declared self-registration permission set '${setName}' cannot be resolved in ` + + 'sys_permission_set (missing, deactivated, or the permission-set store is unavailable) — ' + + 'self-registration is refused rather than admitting an ungranted user. ' + + 'Create/activate the set, or point selfRegistrationPermissionSet at an existing one.'; + this.audienceLogError(`[audience] ${message}`); + return { error: AUDIENCE_CONFIG_ERROR, errorDescription: message }; + } + if (email) this.stageSelfRegistrationGrant(email, setName); + } + return undefined; + } catch (error) { + // Fail CLOSED: an admission question this gate cannot answer must not + // admit. Operator/provider classes never reach I/O, so they cannot land + // here. + const message = 'audience admission could not be evaluated — self-registration refused.'; + this.audienceLogError(`[audience] ${message}`, { + error: (error as Error)?.message ?? String(error), + }); + return { error: AUDIENCE_CONFIG_ERROR, errorDescription: message }; + } + } + + /** + * Zero HUMAN users exist — the first-run owner wizard / seeded admin + * creating the very first account (a fresh install must never lock its + * operator out). {@link isHumanUserRow} owns the population predicate and + * says why it is humans rather than rows. An unanswerable probe reads as + * NOT bootstrap (gate stays on) — fail closed is deliberate here, which is + * exactly why the probe must be ANSWERABLE at every seam that asks. + * + * ## Why this reads the data engine and not `ctx.context.adapter` + * + * The first spelling of this probe was `adapter.findOne({ model: 'user', + * where: [] })`, copied from the `disableSignUp` bypass below. On the real + * ObjectQL engine that call does not answer — it THROWS. `where: []` lowers + * to an empty filter, and `requireFindOnePredicate` (#4419) refuses a + * `findOne` that selects no particular record rather than hand back an + * arbitrary row. The `catch` turned that refusal into `false`, so EVERY + * bootstrap creation read as "not bootstrap" and the `invite_only` default + * refused the operator's own first account — with the in-memory harness + * green throughout, because a fake engine has no such guard. + * + * So the probe is expressed the way it can actually be answered: a bounded + * `find` through `withSystemReadContext`, the same ctx-independent data + * path {@link hasPendingInvitationFor} uses one frame away. `find` carries + * no #4419 predicate requirement — asking for a PAGE is a well-posed + * question in a way that asking for an unspecified single row is not. + */ + private async isBootstrapCreation(): Promise { + const engine = this.config.dataEngine; + if (!engine || typeof (engine as any).find !== 'function') return false; + try { + const reader = withSystemReadContext(engine) as any; + const raw = await reader.find(SystemObjectName.USER, { + limit: AuthManager.BOOTSTRAP_USER_PROBE_LIMIT, + }); + const rows: any[] = Array.isArray(raw) ? raw : Array.isArray(raw?.records) ? raw.records : []; + if (rows.some(isHumanUserRow)) return false; + // A page that came back FULL of non-human rows cannot prove there is no + // human on the next page — fail closed rather than guess. + return rows.length < AuthManager.BOOTSTRAP_USER_PROBE_LIMIT; + } catch { + return false; + } + } + + /** + * A pending, unexpired `sys_invitation` row exists for this email. Rows are + * fetched by status and compared lowercased in JS because invitation + * addresses are stored as the inviter typed them while better-auth + * lowercases the registrant's — a case-sensitive store-side equality would + * dead-end `Bob@Acme.com`'s invitee. Bounded read (an environment's pending + * invitations are few); unanswerable ⇒ no carve-out (fail closed). + */ + private async hasPendingInvitationFor(email: string): Promise { + const engine = this.config.dataEngine; + if (!engine || typeof (engine as any).find !== 'function') return false; + const target = email.trim().toLowerCase(); + if (!target) return false; + try { + const reader = withSystemReadContext(engine) as any; + const raw = await reader.find('sys_invitation', { where: { status: 'pending' }, limit: 200 }); + const rows: any[] = Array.isArray(raw) ? raw : Array.isArray(raw?.records) ? raw.records : []; + const nowMs = Date.now(); + for (const row of rows) { + const rowEmail = typeof row?.email === 'string' ? row.email.trim().toLowerCase() : ''; + if (rowEmail !== target) continue; + const expires = row?.expires_at ?? row?.expiresAt; + if (expires != null) { + const expMs = new Date(expires as any).getTime(); + if (Number.isFinite(expMs) && expMs <= nowMs) continue; + } + return true; + } + return false; + } catch { + return false; + } + } + + /** At least one ACTIVE `sys_permission_set` row carries the declared name. */ + private async selfRegistrationSetResolvable(setName: string): Promise { + const rows = await this.findPermissionSetRows(setName); + return rows.some((r) => r?.active !== false && typeof r?.id === 'string' && r.id); + } + + private async findPermissionSetRows(setName: string): Promise { + const engine = this.config.dataEngine; + if (!engine || typeof (engine as any).find !== 'function') return []; + try { + const reader = withSystemReadContext(engine) as any; + const raw = await reader.find('sys_permission_set', { where: { name: setName }, limit: 50 }); + return Array.isArray(raw) ? raw : Array.isArray(raw?.records) ? raw.records : []; + } catch { + return []; + } + } + + private stageSelfRegistrationGrant(email: string, setName: string): void { + this.prunePendingSelfRegistrationGrants(); + this.pendingSelfRegistrationGrants.set(email.trim().toLowerCase(), { + setName, + stagedAtMs: Date.now(), + }); + } + + private prunePendingSelfRegistrationGrants(): void { + const cutoff = Date.now() - AuthManager.SELF_REG_GRANT_STAGE_TTL_MS; + for (const [key, value] of this.pendingSelfRegistrationGrants) { + if (value.stagedAtMs < cutoff) this.pendingSelfRegistrationGrants.delete(key); + } + } + + /** + * Land the staged self-registration grant for a freshly created user — the + * `user.create.after` half of an admitted self-registration. Idempotent + * (existing `(user, set)` pair wins), org-scoped to the same target org the + * membership reconciler resolves (falls back to an unscoped row only when + * no org is resolvable — multi-org self-registration binds no org either). + * + * A failure here is reported at the DURABILITY level (`error` when the sink + * carries it): the system keeps looking normal from the outside while a + * grant the configuration claims has not landed — the declared≠enforced + * state #11739 exists to close. The consequence and the fix ride the first + * line. + */ + private async settleSelfRegistrationGrant(user: any): Promise { + let staged: { setName: string; stagedAtMs: number } | undefined; + try { + const email = typeof user?.email === 'string' ? user.email.trim().toLowerCase() : ''; + const userId = typeof user?.id === 'string' ? user.id : ''; + if (!email || !userId) return; + this.prunePendingSelfRegistrationGrants(); + staged = this.pendingSelfRegistrationGrants.get(email); + if (!staged) return; + this.pendingSelfRegistrationGrants.delete(email); + const engine = this.config.dataEngine; + if (!engine) { + this.reportUngrantedSelfRegistrant(userId, staged.setName, 'no data engine is available'); + return; + } + const sys = withSystemReadContext(engine) as any; + let organizationId: string | null = null; + try { + const tenancy = this.config.getTenancy?.(); + organizationId = tenancy ? await tenancy.defaultOrgId() : null; + } catch { + organizationId = null; + } + const rows = (await this.findPermissionSetRows(staged.setName)).filter( + (r) => r?.active !== false && typeof r?.id === 'string' && r.id, + ); + const row = + (organizationId ? rows.find((r) => r?.organization_id === organizationId) : undefined) ?? + rows.find((r) => r?.organization_id == null) ?? + (rows.length === 1 ? rows[0] : undefined); + if (!row) { + this.reportUngrantedSelfRegistrant( + userId, + staged.setName, + organizationId + ? `no active sys_permission_set row named '${staged.setName}' resolves for organization ${organizationId}` + : `no active sys_permission_set row named '${staged.setName}' resolves`, + ); + return; + } + const existingRaw = await sys.find('sys_user_permission_set', { + where: { user_id: userId, permission_set_id: row.id }, + limit: 1, + }); + const existing: any[] = Array.isArray(existingRaw) + ? existingRaw + : Array.isArray(existingRaw?.records) + ? existingRaw.records + : []; + if (existing.length > 0) return; + const id = `ups_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`; + await sys.insert('sys_user_permission_set', { + id, + user_id: userId, + permission_set_id: row.id, + ...(organizationId ? { organization_id: organizationId } : {}), + }); + this.config.logger?.info?.('[audience] granted the declared self-registration permission set', { + userId, + permissionSet: staged.setName, + organizationId: organizationId ?? undefined, + }); + } catch (error) { + this.reportUngrantedSelfRegistrant( + typeof user?.id === 'string' ? user.id : '(unknown)', + staged?.setName ?? '(unknown)', + (error as Error)?.message ?? String(error), + ); + } + } + + private reportUngrantedSelfRegistrant(userId: string, setName: string, cause: string): void { + this.audienceLogError( + `[audience] a self-registrant was ADMITTED but the declared permission set '${setName}' was NOT granted ` + + `(user ${userId}) — the account works and everything looks normal, but it holds only baseline access, ` + + `and nothing retries this. Fix: verify the set exists and is active for the target organization, then grant it ` + + `manually (sys_user_permission_set) or have the user re-created. Cause: ${cause}`, + ); + } + /** * Inject (or replace) the outbound email service used by better-auth * callbacks. Safe to call after construction but BEFORE the first @@ -4208,10 +4665,23 @@ export class AuthManager { // `enabled` stays true (break-glass), but signup is forced off and the UI // suppresses the password form via `features.ssoEnforced` below. const ssoOnly = this.resolveSsoOnly(); + // [#11739] The audience posture, resolved once for this response: the + // forced-verification mirror below and `features.audiencePosture` must + // answer from the same read the enforcement path uses (getAudience — + // the getMembershipPolicy pattern). Note `disableSignUp` is deliberately + // NOT forced by the posture: under invite_only the sign-up route still + // admits a pending invitee, so hiding the form entirely would dead-end + // invited users — the posture rides its own key instead. + const audience = this.getAudience(); const emailPassword = { enabled: emailPasswordConfig.enabled !== false, // Default to true disableSignUp: ssoOnly ? true : (disableSignUpFromEnv ?? emailPasswordConfig.disableSignUp ?? false), - requireEmailVerification: emailPasswordConfig.requireEmailVerification ?? false, + // Mirrors the wiring in createAuthInstance(): a self-registration- + // permitting posture forces verification ON — the advertised flag must + // not disagree with the wired one. + requireEmailVerification: audiencePermitsSelfRegistration(audience.posture) + ? true + : (emailPasswordConfig.requireEmailVerification ?? false), }; // Extract enabled features @@ -4279,6 +4749,14 @@ export class AuthManager { // `group` the org switcher picks the WRITE target while reads span every // organization the member belongs to ("all my organizations" views). tenancyPosture, + // [#11739] WHICH audience posture is in force (invite_only | + // email_domain | open) — a value, not a flag (registered in + // PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS beside tenancyPosture). Lets the + // login surface render honest messaging instead of a sign-up form the + // server will refuse. An off-vocabulary config value reports the safe + // coercion (invite_only) here while the enforcement path refuses with + // AUTH_CONFIG_ERROR — display never fails open. + audiencePosture: audience.posture, // ADR-0093 D5 — brand the degraded state everywhere an operator looks. // True iff multi-org was requested but tenant isolation is inactive // (booted only because OS_ALLOW_DEGRADED_TENANCY=1). The console can @@ -4979,6 +5457,13 @@ export class AuthManager { const hostUserAfter = (host as any)?.user?.create?.after; const membershipReconciler = async (user: any) => { await this.settleMembership(user?.id); + // [#11739] The second half of an admitted SELF-registration: land the + // DECLARED permission-set grant staged by the validateUserInfo gate. + // Same seam as the membership bind (the one every creation path flows + // through), same never-throw posture — but a failure here is reported + // at the durability level, because an admitted-but-ungranted + // self-registrant is the declared≠enforced state this card closes. + await this.settleSelfRegistrationGrant(user); }; const userAfter = hostUserAfter ? async (user: any, ctx: any) => { diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 3829572b6a..8a5fd2e2a6 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -8,8 +8,10 @@ import { type SettingsChangeHandler, type SettingsUnsubscribe, SystemObjectName, - SystemUserId, } 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. +import { isHumanUserRow } from './audience-posture.js'; import { // ADR-0048 — the Setup/Studio/Account apps moved to their own packages // (@objectstack/{setup,studio,account}); plugin-auth no longer registers them. @@ -1554,17 +1556,23 @@ export class AuthPlugin implements Plugin { if (!ql || typeof ql.find !== 'function') return; try { - // Only seed when no HUMAN user exists yet. A fresh DB still contains - // 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. + // 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. const rows = await ql .find(SystemObjectName.USER, { where: {}, limit: 50 }, { context: { isSystem: true } }) .catch(() => []); - const humans = (Array.isArray(rows) ? rows : []) - .filter((u: any) => u && u.id !== SystemUserId.SYSTEM && u.role !== 'system'); + const humans = (Array.isArray(rows) ? rows : []).filter(isHumanUserRow); if (humans.length > 0) { ctx.logger.debug('[auth] dev admin seed skipped — a user already exists'); // `os dev` defaults to a persistent DB, so the seed fires exactly diff --git a/packages/plugins/plugin-auth/src/break-glass-guard-authentication-order.test.ts b/packages/plugins/plugin-auth/src/break-glass-guard-authentication-order.test.ts index 3bd8c08269..8fe2d5262a 100644 --- a/packages/plugins/plugin-auth/src/break-glass-guard-authentication-order.test.ts +++ b/packages/plugins/plugin-auth/src/break-glass-guard-authentication-order.test.ts @@ -40,6 +40,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AuthManager } from './auth-manager'; import { createMemoryEngine } from './impersonation-bearer-rotation.test'; import { LAST_LOCAL_CREDENTIAL_CODE } from './last-local-credential'; +import { inviteForAudienceGate } from './audience-gate-test-support'; const SECRET = 'test-secret-at-least-32-chars-long!!'; const PASSWORD = 'S3cure!Passw0rd-10776'; @@ -106,6 +107,9 @@ async function seedDeployment() { ['admin.10776@example.com', 'Managed Admin'], ['ordinary.10776@example.com', 'Ordinary User'], ]) { + // [#11739] default posture invite_only: users beyond the first enter + // through the invitation carve-out (see audience-gate-test-support). + inviteForAudienceGate(engine, email); const res = await post(manager, '/sign-up/email', { email, password: PASSWORD, name }); expect(res.status, `sign-up ${email}: ${await res.clone().text()}`).toBe(200); } diff --git a/packages/plugins/plugin-auth/src/break-glass-guard-self-service-target.test.ts b/packages/plugins/plugin-auth/src/break-glass-guard-self-service-target.test.ts index 0063ec3901..fb1550f47e 100644 --- a/packages/plugins/plugin-auth/src/break-glass-guard-self-service-target.test.ts +++ b/packages/plugins/plugin-auth/src/break-glass-guard-self-service-target.test.ts @@ -36,6 +36,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AuthManager } from './auth-manager'; import { createMemoryEngine } from './impersonation-bearer-rotation.test'; import { LAST_LOCAL_CREDENTIAL_CODE } from './last-local-credential'; +import { inviteForAudienceGate } from './audience-gate-test-support'; const SECRET = 'test-secret-at-least-32-chars-long!!'; const PASSWORD = 'S3cure!Passw0rd-11074'; @@ -91,6 +92,9 @@ async function seedDeployment() { ['owner.11074@example.com', 'Break Glass Owner'], ['caller.11074@example.com', 'Ordinary Caller'], ]) { + // [#11739] default posture invite_only: users beyond the first enter + // through the invitation carve-out (see audience-gate-test-support). + inviteForAudienceGate(engine, email); const res = await post(manager, '/sign-up/email', { email, password: PASSWORD, name }); expect(res.status, `sign-up ${email}: ${await res.clone().text()}`).toBe(200); } diff --git a/packages/plugins/plugin-auth/src/change-email-delete-user-wiring.test.ts b/packages/plugins/plugin-auth/src/change-email-delete-user-wiring.test.ts index 7a472519b6..a1e1ac1824 100644 --- a/packages/plugins/plugin-auth/src/change-email-delete-user-wiring.test.ts +++ b/packages/plugins/plugin-auth/src/change-email-delete-user-wiring.test.ts @@ -35,6 +35,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql'; import type { IEmailService, SendEmailResult, SendTemplateInput } from '@objectstack/spec/contracts'; import { AuthManager } from './auth-manager'; +import { inviteForAudienceGate } from './audience-gate-test-support'; // ─────────────────────────────────────────────────────────────────────────── // Harness @@ -174,14 +175,18 @@ function makeManager(engine: MemoryEngine, emailService?: IEmailService): AuthMa } as never); } -const signUp = (manager: AuthManager, email: string) => - manager.handleRequest( +const signUp = (manager: AuthManager, email: string) => { + // [#11739] default posture invite_only: fixture users beyond the first + // enter through the invitation carve-out (see audience-gate-test-support). + inviteForAudienceGate(manager, email); + return manager.handleRequest( new Request(`${AUTH}/sign-up/email`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password: PASSWORD, name: 'Change Email Subject' }), }), ); +}; const cookieFrom = (response: Response): string => (response.headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? '']) diff --git a/packages/plugins/plugin-auth/src/credential-at-rest-posture.test.ts b/packages/plugins/plugin-auth/src/credential-at-rest-posture.test.ts index 555feda612..bdcf0379d9 100644 --- a/packages/plugins/plugin-auth/src/credential-at-rest-posture.test.ts +++ b/packages/plugins/plugin-auth/src/credential-at-rest-posture.test.ts @@ -142,6 +142,7 @@ import { SysOauthConsent, SysJwks, } from '@objectstack/platform-objects'; +import { inviteForAudienceGate } from './audience-gate-test-support'; const BASE = 'http://localhost:3000'; const AUTH = `${BASE}/api/v1/auth`; @@ -251,7 +252,11 @@ function cookiesFrom(response: Response): string { async function signUpAdmin( send: (request: Request) => Promise, email = 'admin@example.com', + engine?: unknown, ): Promise { + // [#11739] default posture invite_only: fixture users beyond the first + // enter through the invitation carve-out (see audience-gate-test-support). + if (engine) await inviteForAudienceGate(engine, email); const res = await send( new Request(`${AUTH}/sign-up/email`, { method: 'POST', @@ -331,7 +336,7 @@ describe('[#8192] sys_scim_provider.scim_token is hashed at rest — via the rep const manager = makeManager(engine); const send = (request: Request) => manager.handleRequest(request); - const cookie = await signUpAdmin(send); + const cookie = await signUpAdmin(send, undefined, engine); const organizationId = await createOrganization(send, cookie, 'probe-org-8192'); const bearer = await generateScimToken(send, cookie, organizationId); @@ -370,7 +375,7 @@ describe('[#8192] sys_scim_provider.scim_token is hashed at rest — via the rep const manager = makeManager(engine); const send = (request: Request) => manager.handleRequest(request); - const cookie = await signUpAdmin(send); + const cookie = await signUpAdmin(send, undefined, engine); const organizationId = await createOrganization(send, cookie, 'probe-org-8192-auth'); const bearer = await generateScimToken(send, cookie, organizationId); @@ -471,7 +476,7 @@ describe('[#8192] the control arm — what the SCIM plugin does with NO option', }); const send = (request: Request) => auth.handler(request); - const cookie = await signUpAdmin(send, 'control@example.com'); + const cookie = await signUpAdmin(send, 'control@example.com', engine); const organizationId = await createOrganization(send, cookie, 'control-org-8192'); const bearer = await generateScimToken(send, cookie, organizationId); diff --git a/packages/plugins/plugin-auth/src/impersonation-bearer-rotation.test.ts b/packages/plugins/plugin-auth/src/impersonation-bearer-rotation.test.ts index dad565c567..9484d41b72 100644 --- a/packages/plugins/plugin-auth/src/impersonation-bearer-rotation.test.ts +++ b/packages/plugins/plugin-auth/src/impersonation-bearer-rotation.test.ts @@ -27,6 +27,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql'; import { AuthManager } from './auth-manager'; +import { inviteForAudienceGate } from './audience-gate-test-support'; import { ADMIN_SESSION_RECOVERY_REQUEST_HEADER, ADMIN_SESSION_RECOVERY_RESPONSE_HEADER, @@ -141,14 +142,18 @@ const makeManager = (engine: any) => plugins: { admin: true }, } as any); -const signUp = (manager: AuthManager, email: string, name: string) => - manager.handleRequest( +const signUp = (manager: AuthManager, email: string, name: string) => { + // [#11739] default posture invite_only: fixture users beyond the first + // enter through the invitation carve-out (see audience-gate-test-support). + inviteForAudienceGate(manager, email); + return manager.handleRequest( new Request(`${BASE}/sign-up/email`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password: PASSWORD, name }), }), ); +}; const signIn = (manager: AuthManager, email: string) => manager.handleRequest( diff --git a/packages/plugins/plugin-auth/src/organization-add-member.test.ts b/packages/plugins/plugin-auth/src/organization-add-member.test.ts index 7173ee1a5f..db3e87026f 100644 --- a/packages/plugins/plugin-auth/src/organization-add-member.test.ts +++ b/packages/plugins/plugin-auth/src/organization-add-member.test.ts @@ -36,6 +36,7 @@ import { AuthManager } from './auth-manager'; import { AuthPlugin } from './auth-plugin'; import { runOrganizationAddMember } from './organization-add-member.js'; import type { PluginContext } from '@objectstack/core'; +import { inviteForAudienceGate } from './audience-gate-test-support'; const SECRET = 'test-secret-at-least-32-chars-long!!'; const BASE = 'http://localhost:3000'; @@ -167,6 +168,9 @@ const viaManager = (manager: AuthManager, path: string, init: RequestInit, cooki ); const signUp = async (manager: AuthManager, engine: MemoryEngine, email: string) => { + // [#11739] default posture invite_only: fixture users beyond the first + // enter through the invitation carve-out (see audience-gate-test-support). + inviteForAudienceGate(engine, email); const res = await viaManager(manager, '/sign-up/email', { method: 'POST', body: JSON.stringify({ email, password: PASSWORD, name: email }), diff --git a/packages/plugins/plugin-auth/src/platform-admin-standing.consolidation.test.ts b/packages/plugins/plugin-auth/src/platform-admin-standing.consolidation.test.ts index 9d38571fde..1e1d6fc2ec 100644 --- a/packages/plugins/plugin-auth/src/platform-admin-standing.consolidation.test.ts +++ b/packages/plugins/plugin-auth/src/platform-admin-standing.consolidation.test.ts @@ -48,6 +48,7 @@ import { AuthManager } from './auth-manager'; // to a plain `.ts` helper, which would remove it from that gate's sight // entirely (the gate discovers doubles by walking `*.test.ts` only). import { createMemoryEngine } from './impersonation-bearer-rotation.test'; +import { inviteForAudienceGate } from './audience-gate-test-support'; const SECRET = 'test-secret-at-least-32-chars-long!!'; const PASSWORD = 'S3cure!Passw0rd-10348'; @@ -68,14 +69,18 @@ const makeManager = (engine: any) => plugins: { admin: true, sso: true }, } as any); -const signUp = (manager: AuthManager, email: string, name: string) => - manager.handleRequest( +const signUp = (manager: AuthManager, email: string, name: string) => { + // [#11739] default posture invite_only: fixture users beyond the first + // enter through the invitation carve-out (see audience-gate-test-support). + inviteForAudienceGate(manager, email); + return manager.handleRequest( new Request(`${BASE}/sign-up/email`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password: PASSWORD, name }), }), ); +}; const signIn = (manager: AuthManager, email: string) => manager.handleRequest( diff --git a/packages/plugins/plugin-auth/src/remove-member-permission-guard.test.ts b/packages/plugins/plugin-auth/src/remove-member-permission-guard.test.ts index d2cac55830..fa3652eb21 100644 --- a/packages/plugins/plugin-auth/src/remove-member-permission-guard.test.ts +++ b/packages/plugins/plugin-auth/src/remove-member-permission-guard.test.ts @@ -49,6 +49,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql'; import { AuthManager } from './auth-manager'; +import { inviteForAudienceGate } from './audience-gate-test-support'; const SECRET = 'test-secret-at-least-32-chars-long!!'; const BASE = 'http://localhost:3000'; @@ -178,6 +179,9 @@ const post = (manager: AuthManager, path: string, body: unknown, cookie?: string ); const signUp = async (manager: AuthManager, engine: MemoryEngine, email: string) => { + // [#11739] default posture invite_only: fixture users beyond the first + // enter through the invitation carve-out (see audience-gate-test-support). + inviteForAudienceGate(engine, email); const res = await post(manager, '/sign-up/email', { email, password: PASSWORD, name: email }); expect(res.status, await res.clone().text()).toBe(200); const user = (engine.tables.get('sys_user') ?? []).find((u) => u.email === email); diff --git a/packages/plugins/plugin-auth/src/remove-user-atomicity.test.ts b/packages/plugins/plugin-auth/src/remove-user-atomicity.test.ts index ca4ebfa6d8..dcb373adbc 100644 --- a/packages/plugins/plugin-auth/src/remove-user-atomicity.test.ts +++ b/packages/plugins/plugin-auth/src/remove-user-atomicity.test.ts @@ -36,6 +36,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql'; import { SysMember } from '@objectstack/platform-objects'; import { AuthManager } from './auth-manager'; +import { inviteForAudienceGate } from './audience-gate-test-support'; const SECRET = 'test-secret-at-least-32-chars-long!!'; const BASE = 'http://localhost:3000'; @@ -264,6 +265,9 @@ const post = (manager: AuthManager, path: string, body: unknown, cookie?: string ); const signUp = async (manager: AuthManager, engine: MemoryEngine, email: string) => { + // [#11739] default posture invite_only: fixture users beyond the first + // enter through the invitation carve-out (see audience-gate-test-support). + inviteForAudienceGate(engine, email); const res = await post(manager, '/sign-up/email', { email, password: PASSWORD, name: email }); expect(res.status, await res.clone().text()).toBe(200); const user = (engine.tables.get('sys_user') ?? []).find((u: any) => u.email === email); diff --git a/packages/plugins/plugin-auth/src/revoke-session-match-guard.test.ts b/packages/plugins/plugin-auth/src/revoke-session-match-guard.test.ts index e86f06ec92..aec1f09e0c 100644 --- a/packages/plugins/plugin-auth/src/revoke-session-match-guard.test.ts +++ b/packages/plugins/plugin-auth/src/revoke-session-match-guard.test.ts @@ -23,6 +23,7 @@ import { REVOKE_SESSION_NOT_FOUND_MESSAGE, revokeTargetsCallerSession, } from './revoke-session-match-guard'; +import { inviteForAudienceGate } from './audience-gate-test-support'; /** * In-memory IDataEngine — the `session-tombstone.test.ts` harness, unchanged, @@ -134,8 +135,12 @@ const post = (manager: AuthManager, path: string, cookie?: string, body?: unknow }), ); -const signUp = (manager: AuthManager, email: string) => - post(manager, 'sign-up/email', undefined, { email, password: PASSWORD, name: 'RevokeGuard' }); +const signUp = (manager: AuthManager, email: string) => { + // [#11739] default posture invite_only: fixture users beyond the first + // enter through the invitation carve-out (see audience-gate-test-support). + inviteForAudienceGate(manager, email); + return post(manager, 'sign-up/email', undefined, { email, password: PASSWORD, name: 'RevokeGuard' }); +}; const getSession = (manager: AuthManager, cookie: string) => manager.handleRequest( diff --git a/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts b/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts index 79a27c678c..1553b92abe 100644 --- a/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts +++ b/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts @@ -75,6 +75,7 @@ import { SysTeamMember, SysSsoProvider, } from '@objectstack/platform-objects'; +import { inviteForAudienceGate } from './audience-gate-test-support'; const BASE = 'http://localhost:3000'; const AUTH = `${BASE}/api/v1/auth`; @@ -160,7 +161,11 @@ const cookiesFrom = (res: Response): string => async function signUp( send: (r: Request) => Promise, email: string, + engine?: unknown, ): Promise { + // [#11739] default posture invite_only: fixture users beyond the first + // enter through the invitation carve-out (see audience-gate-test-support). + if (engine) await inviteForAudienceGate(engine, email); const res = await send( new Request(`${AUTH}/sign-up/email`, { method: 'POST', @@ -265,7 +270,7 @@ describe('[#10009] direct /sso/register — the ADR-0024 before-hook admits plat const send = (r: Request) => manager.handleRequest(r); const email = 'orgowner@example.com'; - const cookie = await signUp(send, email); + const cookie = await signUp(send, email, engine); const orgId = await createOrg(send, cookie, 'probe-org-10009'); // The principal really is an org OWNER — the fixture's claim, verified. @@ -301,7 +306,7 @@ describe('[#10009] direct /sso/register — the ADR-0024 before-hook admits plat const send = (r: Request) => manager.handleRequest(r); const email = 'platformadmin@example.com'; - const cookie = await signUp(send, email); + const cookie = await signUp(send, email, engine); await grantPlatformAdmin(engine, await userIdOf(engine, email)); await expectLegacyRoleScalarIsNotAdmin(engine, email); @@ -359,7 +364,7 @@ describe('[#10009] the two doors onto SSO registration now answer the same org o const manager = makeManager(engine); const send = (r: Request) => manager.handleRequest(r); - const cookie = await signUp(send, 'orgowner@example.com'); + const cookie = await signUp(send, 'orgowner@example.com', engine); await createOrg(send, cookie, 'probe-org-10009-both'); // Door 1 — the #9653 bridge (unchanged by this card). diff --git a/packages/plugins/plugin-auth/src/vendor-admin-refusal-envelope.test.ts b/packages/plugins/plugin-auth/src/vendor-admin-refusal-envelope.test.ts index 2ec3c5449a..c699d82532 100644 --- a/packages/plugins/plugin-auth/src/vendor-admin-refusal-envelope.test.ts +++ b/packages/plugins/plugin-auth/src/vendor-admin-refusal-envelope.test.ts @@ -28,6 +28,7 @@ import { import { judgePlatformAdmin } from './platform-admin-gate'; import { AuthManager } from './auth-manager'; import { createMemoryEngine } from './impersonation-bearer-rotation.test'; +import { inviteForAudienceGate } from './audience-gate-test-support'; // ─────────────────────────────────────────────────────────────────────────── // The pure normalizer @@ -238,6 +239,9 @@ describe('#10349 — through AuthManager.handleRequest on the real vendor pipeli ['padmin.10349@example.com', 'Platform Admin'], ['target.10349@example.com', 'Target'], ]) { + // [#11739] default posture invite_only: users beyond the first enter + // through the invitation carve-out (see audience-gate-test-support). + await inviteForAudienceGate(engine, email); await post(manager, '/sign-up/email', { email, password: PASSWORD, name }); } const rows = (engine.tables.get('sys_user') ?? []) as any[]; diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index d7782844eb..683577f37c 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -2,6 +2,7 @@ "description": "Every exported `name (kind)` of one published entry point of @objectstack/spec — the breadth half of the ADR-0059 backward-compatibility gate. Sharded by entry point (#5837) so two PRs touching different entry points never share a file. Reads the BUILT dist/*.d.ts: regenerate with `pnpm --filter @objectstack/spec gen:api-surface` after a real build.", "entry": "./system", "exports": [ + "AUDIENCE_POSTURES (const)", "AccessControlConfig (type)", "AccessControlConfigParsed (type)", "AccessControlConfigSchema (const)", @@ -28,6 +29,10 @@ "AppManifestSchema (const)", "AudienceBook (interface)", "AudienceCaller (interface)", + "AudienceConfig (type)", + "AudienceConfigParsed (type)", + "AudienceConfigSchema (const)", + "AudiencePosture (type)", "AuthConfig (type)", "AuthConfigParsed (type)", "AuthConfigSchema (const)", @@ -756,6 +761,7 @@ "WorkerStats (type)", "WorkerStatsSchema (const)", "audienceAllows (function)", + "audiencePermitsSelfRegistration (function)", "authorisesIrreversibleAction (function)", "azureBlobStorageExample (const)", "defineBook (function)", @@ -771,6 +777,7 @@ "hasPlatformObjectPrefix (function)", "inProcessServiceMessage (function)", "interpolateValidationMessage (function)", + "isAudiencePosture (function)", "isDataMigrationFlagVerified (function)", "isPlatformProvidedObjectName (function)", "isPlatformProvidedToolName (function)", diff --git a/packages/spec/authorable-defaults/system.json b/packages/spec/authorable-defaults/system.json index 7d90866fec..55f704b18c 100644 --- a/packages/spec/authorable-defaults/system.json +++ b/packages/spec/authorable-defaults/system.json @@ -15,6 +15,7 @@ "system/AppManifest:objects = []", "system/AppManifest:seedData = []", "system/AppManifest:views = []", + "system/AudienceConfig:posture = \"invite_only\"", "system/AuthConfig:uiBasePath = \"/_console\"", "system/AuthPluginConfig:admin = false", "system/AuthPluginConfig:deviceAuthorization = false", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 34ae44afd5..4e3485ace6 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -53,7 +53,11 @@ "system/AppManifest:seedData", "system/AppManifest:version", "system/AppManifest:views", + "system/AudienceConfig:allowedEmailDomains", + "system/AudienceConfig:posture", + "system/AudienceConfig:selfRegistrationPermissionSet", "system/AuthConfig:advanced", + "system/AuthConfig:audience", "system/AuthConfig:baseUrl", "system/AuthConfig:databaseUrl", "system/AuthConfig:emailAndPassword", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 5dde883a9f..72c3512bd7 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -2,6 +2,7 @@ "description": "Which SOURCE DECLARATION each name exported by one public entry point of @objectstack/spec resolves to, after its alias chain is unwound: `# ()`. Two exports share an origin string iff they are the same declaration — so equal origins across two entries are a harmless re-export, and different origins under one name are the #4411 dual-source trap. Generated from src/ (no build needed) and read by the export-surface pin tests, which compare against it instead of each building their own ts.createProgram — that was ~55s of compilation per CI lap and a non-deterministic timeout that ejected unrelated PRs from the merge queue (#4796). Sharded by entry point (#5837) so two retirement PRs never share a file. Carries NO line numbers: the pins asserted the line as `\\d+`, and recording it would rewrite this artifact on every edit that shifts a line in any .zod.ts. Regenerate with `pnpm --filter @objectstack/spec gen:export-origins` and read the diff.", "entry": "./system", "exports": { + "AUDIENCE_POSTURES": "src/system/auth-config.zod.ts#AUDIENCE_POSTURES (const)", "AccessControlConfig": "src/system/object-storage.zod.ts#AccessControlConfig (type)", "AccessControlConfigParsed": "src/system/object-storage.zod.ts#AccessControlConfigParsed (type)", "AccessControlConfigSchema": "src/system/object-storage.zod.ts#AccessControlConfigSchema (const)", @@ -28,6 +29,10 @@ "AppManifestSchema": "src/system/app-install.zod.ts#AppManifestSchema (const)", "AudienceBook": "src/system/book.zod.ts#AudienceBook (interface)", "AudienceCaller": "src/system/book.zod.ts#AudienceCaller (interface)", + "AudienceConfig": "src/system/auth-config.zod.ts#AudienceConfig (type)", + "AudienceConfigParsed": "src/system/auth-config.zod.ts#AudienceConfigParsed (type)", + "AudienceConfigSchema": "src/system/auth-config.zod.ts#AudienceConfigSchema (const)", + "AudiencePosture": "src/system/auth-config.zod.ts#AudiencePosture (type)", "AuthConfig": "src/system/auth-config.zod.ts#AuthConfig (type)", "AuthConfigParsed": "src/system/auth-config.zod.ts#AuthConfigParsed (type)", "AuthConfigSchema": "src/system/auth-config.zod.ts#AuthConfigSchema (const)", @@ -756,6 +761,7 @@ "WorkerStats": "src/system/worker.zod.ts#WorkerStats (type)", "WorkerStatsSchema": "src/system/worker.zod.ts#WorkerStatsSchema (const)", "audienceAllows": "src/system/book.zod.ts#audienceAllows (function)", + "audiencePermitsSelfRegistration": "src/system/auth-config.zod.ts#audiencePermitsSelfRegistration (function)", "authorisesIrreversibleAction": "src/system/migration.zod.ts#authorisesIrreversibleAction (function)", "azureBlobStorageExample": "src/system/object-storage.zod.ts#azureBlobStorageExample (const)", "defineBook": "src/system/book.zod.ts#defineBook (function)", @@ -771,6 +777,7 @@ "hasPlatformObjectPrefix": "src/system/constants/platform-object-names.ts#hasPlatformObjectPrefix (function)", "inProcessServiceMessage": "src/system/core-services.zod.ts#inProcessServiceMessage (function)", "interpolateValidationMessage": "src/system/validation-message.ts#interpolateValidationMessage (function)", + "isAudiencePosture": "src/system/auth-config.zod.ts#isAudiencePosture (function)", "isDataMigrationFlagVerified": "src/system/migration.zod.ts#isDataMigrationFlagVerified (function)", "isPlatformProvidedObjectName": "src/system/constants/platform-object-names.ts#isPlatformProvidedObjectName (function)", "isPlatformProvidedToolName": "src/system/constants/platform-tool-names.ts#isPlatformProvidedToolName (function)", diff --git a/packages/spec/json-schema.manifest/system.json b/packages/spec/json-schema.manifest/system.json index df910b6964..356752993b 100644 --- a/packages/spec/json-schema.manifest/system.json +++ b/packages/spec/json-schema.manifest/system.json @@ -11,6 +11,7 @@ "system/AppInstallRequest", "system/AppInstallResult", "system/AppManifest", + "system/AudienceConfig", "system/AuthConfig", "system/AuthPluginConfig", "system/AuthProviderConfig", diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 181b53598c..84659682c9 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -339,6 +339,7 @@ export const ERROR_CODE_LEDGER = { 'CREATE_FAILED', 'DOMAIN_VERIFICATION_DISABLED', // domain verification is off on this deployment 'DOMAIN_VERIFICATION_FAILED', // pass-through from better-auth + 'EMAIL_DOMAIN_NOT_ALLOWED', // [#11739] audience posture email_domain: the address's domain is off the allowlist 'EMAIL_SERVICE_REQUIRED', 'ENV_ACCESS_DENIED', 'INVALID_EMAIL', @@ -358,6 +359,7 @@ export const ERROR_CODE_LEDGER = { 'OAUTH_REGISTER_FAILED', // better-auth rejected the client registration 'PHONE_NOT_ENABLED', 'SAML_REGISTER_FAILED', + 'SELF_REGISTRATION_CLOSED', // [#11739] audience posture invite_only: self-registration is closed (no pending invitation for this address) 'SSO_REGISTER_FAILED', 'SSO_REGISTER_FORBIDDEN', 'USER_ALREADY_EXISTS', // pass-through from better-auth diff --git a/packages/spec/src/kernel/public-auth-features.ts b/packages/spec/src/kernel/public-auth-features.ts index 73828ea0c4..8d27ce4d2e 100644 --- a/packages/spec/src/kernel/public-auth-features.ts +++ b/packages/spec/src/kernel/public-auth-features.ts @@ -252,8 +252,17 @@ export const PUBLIC_AUTH_FEATURE_NAMES = Object.keys(PUBLIC_AUTH_FEATURES) as [ * this tells the console how to render org context — under `group` the org * switcher picks the WRITE target and reads span every organization the member * belongs to. + * + * `audiencePosture` (#11739) reports WHICH of `invite_only` | `email_domain` | + * `open` is in force — the declared answer to "who may self-register into this + * environment". It gates no spec input: `emailPassword.disableSignUp` remains + * the login UI's boolean for hiding the sign-up form (it is NOT forced by the + * posture — under `invite_only` the sign-up route still admits a pending + * invitee, so hiding the form would dead-end invited users), while this value + * lets the login surface render honest messaging ("registration is by + * invitation") instead of a form the server will refuse. */ -export const PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS = ['termsUrl', 'privacyUrl', 'tenancyPosture'] as const; +export const PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS = ['termsUrl', 'privacyUrl', 'tenancyPosture', 'audiencePosture'] as const; /** * Capabilities that are RESERVED but deliberately **not advertised** — the diff --git a/packages/spec/src/migrations/entries/semantic/18.audience-posture-default-invite-only.ts b/packages/spec/src/migrations/entries/semantic/18.audience-posture-default-invite-only.ts new file mode 100644 index 0000000000..3d85ff7ecc --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.audience-posture-default-invite-only.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'audience-posture-default-invite-only', + surface: 'system.AuthConfig.audience', + replacement: + "explicit `auth: { audience: { posture: 'open' | 'email_domain', selfRegistrationPermissionSet: '' } }` " + + '(deployments that intend open self-registration only)', + reason: + 'The default audience posture flipped in #11739: an UNDECLARED `audience` now means ' + + '`invite_only` — email/password self-registration (and social-provider JIT sign-up) is ' + + 'refused with 403 SELF_REGISTRATION_CLOSED unless the address holds a pending invitation. ' + + 'Previously the emergent default was open self-registration with no email verification. ' + + 'Whether a deployment truly means to admit strangers (public portal) or was open only by ' + + 'accident is a security judgment no transform can make — and a posture that opens ' + + 'self-registration must also DECLARE the permission set a self-registrant receives and ' + + 'accepts forced email verification, neither of which can be invented mechanically.', + acceptanceCriteria: + 'A deployment that relies on open self-registration declares `audience.posture` ' + + "('open', or 'email_domain' with `allowedEmailDomains`) plus `selfRegistrationPermissionSet`, " + + 'and its sign-up flow still works end to end (verification email delivered, registrant holds ' + + 'the declared permission set). Every other deployment verifies operators can still add users ' + + '(invitation, admin create-user / import, SCIM, or an operator-registered identity provider) ' + + 'and that anonymous sign-up now answers 403 SELF_REGISTRATION_CLOSED.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index ed4d99b786..7ce134d2ec 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5275,6 +5275,29 @@ const step18: MigrationStep = { + 'every `/analytics/query` body\'s `timeDimensions[]` items carry only ' + '`dimension`/`granularity`/`dateRange`. Declared keys parse byte-identically to before.', }, + { + id: 'audience-posture-default-invite-only', + surface: 'system.AuthConfig.audience', + replacement: + "explicit `auth: { audience: { posture: 'open' | 'email_domain', selfRegistrationPermissionSet: '' } }` " + + '(deployments that intend open self-registration only)', + reason: + 'The default audience posture flipped in #11739: an UNDECLARED `audience` now means ' + + '`invite_only` — email/password self-registration (and social-provider JIT sign-up) is ' + + 'refused with 403 SELF_REGISTRATION_CLOSED unless the address holds a pending invitation. ' + + 'Previously the emergent default was open self-registration with no email verification. ' + + 'Whether a deployment truly means to admit strangers (public portal) or was open only by ' + + 'accident is a security judgment no transform can make — and a posture that opens ' + + 'self-registration must also DECLARE the permission set a self-registrant receives and ' + + 'accepts forced email verification, neither of which can be invented mechanically.', + acceptanceCriteria: + 'A deployment that relies on open self-registration declares `audience.posture` ' + + "('open', or 'email_domain' with `allowedEmailDomains`) plus `selfRegistrationPermissionSet`, " + + 'and its sign-up flow still works end to end (verification email delivered, registrant holds ' + + 'the declared permission set). Every other deployment verifies operators can still add users ' + + '(invitation, admin create-user / import, SCIM, or an operator-registered identity provider) ' + + 'and that anonymous sign-up now answers 403 SELF_REGISTRATION_CLOSED.', + }, { id: 'cbp-master-detail-required-forced', surface: 'object.fields..required on a `master_detail` reference under ' diff --git a/packages/spec/src/system/auth-config.test.ts b/packages/spec/src/system/auth-config.test.ts index b1a594c488..8cf57cb55c 100644 --- a/packages/spec/src/system/auth-config.test.ts +++ b/packages/spec/src/system/auth-config.test.ts @@ -8,6 +8,10 @@ import { EmailAndPasswordConfigSchema, EmailVerificationConfigSchema, AdvancedAuthConfigSchema, + AudienceConfigSchema, + AUDIENCE_POSTURES, + isAudiencePosture, + audiencePermitsSelfRegistration, } from './auth-config.zod'; describe('AuthProviderConfigSchema', () => { @@ -414,3 +418,93 @@ describe('AuthConfigSchema – new passthrough fields', () => { expect(config.advanced?.crossSubDomainCookies?.enabled).toBe(true); }); }); + +describe('AudienceConfigSchema (#11739)', () => { + it('defaults an undeclared posture to invite_only (the ruled safe default)', () => { + const parsed = AudienceConfigSchema.parse({}); + expect(parsed.posture).toBe('invite_only'); + }); + + it('exposes the closed vocabulary as a runtime value list with a type guard', () => { + expect(AUDIENCE_POSTURES).toEqual(['invite_only', 'email_domain', 'open']); + for (const p of AUDIENCE_POSTURES) expect(isAudiencePosture(p)).toBe(true); + // Off-vocabulary values — including the fail-open shapes the + // MembershipPolicy precedent exists for — are NOT postures. + for (const bad of ['inviteOnly', 'invite-only', 'OPEN', 'domain', '', null, undefined, 0, {}]) { + expect(isAudiencePosture(bad)).toBe(false); + } + }); + + it('classifies which postures permit self-registration', () => { + expect(audiencePermitsSelfRegistration('invite_only')).toBe(false); + expect(audiencePermitsSelfRegistration('email_domain')).toBe(true); + expect(audiencePermitsSelfRegistration('open')).toBe(true); + }); + + it('refuses an off-vocabulary posture loudly (never coerces)', () => { + for (const bad of ['inviteOnly', 'invite-only', 'Open', 'anything']) { + expect(() => AudienceConfigSchema.parse({ posture: bad })).toThrow(); + } + }); + + it('email_domain requires a non-empty domain list (completeness predicate)', () => { + expect(() => AudienceConfigSchema.parse({ posture: 'email_domain', selfRegistrationPermissionSet: 'portal_user' })).toThrow(/allowedEmailDomains/); + expect(() => AudienceConfigSchema.parse({ + posture: 'email_domain', allowedEmailDomains: [], selfRegistrationPermissionSet: 'portal_user', + })).toThrow(/non-empty/); + const ok = AudienceConfigSchema.parse({ + posture: 'email_domain', allowedEmailDomains: ['acme.com'], selfRegistrationPermissionSet: 'portal_user', + }); + expect(ok.allowedEmailDomains).toEqual(['acme.com']); + }); + + it('refuses a domain list under a posture that never reads it (declared-but-inert, ADR-0078)', () => { + expect(() => AudienceConfigSchema.parse({ + posture: 'invite_only', allowedEmailDomains: ['acme.com'], + })).toThrow(/inert/); + expect(() => AudienceConfigSchema.parse({ + posture: 'open', allowedEmailDomains: ['acme.com'], selfRegistrationPermissionSet: 'portal_user', + })).toThrow(/inert/); + }); + + it('validates domain entry shape and refuses duplicates', () => { + for (const bad of ['@acme.com', 'https://acme.com', 'acme', '.acme.com', 'acme.com.', 'a cme.com', '*.acme.com', '']) { + expect(() => AudienceConfigSchema.parse({ + posture: 'email_domain', allowedEmailDomains: [bad], selfRegistrationPermissionSet: 'portal_user', + })).toThrow(); + } + expect(() => AudienceConfigSchema.parse({ + posture: 'email_domain', allowedEmailDomains: ['acme.com', 'ACME.com'], selfRegistrationPermissionSet: 'portal_user', + })).toThrow(/duplicate/); + }); + + it('a self-registration-permitting posture requires the permission set declaration', () => { + expect(() => AudienceConfigSchema.parse({ posture: 'open' })).toThrow(/selfRegistrationPermissionSet/); + expect(() => AudienceConfigSchema.parse({ + posture: 'email_domain', allowedEmailDomains: ['acme.com'], + })).toThrow(/selfRegistrationPermissionSet/); + // Declaring member_default EXPLICITLY is fine — what is retired is the fallback. + const ok = AudienceConfigSchema.parse({ posture: 'open', selfRegistrationPermissionSet: 'member_default' }); + expect(ok.selfRegistrationPermissionSet).toBe('member_default'); + }); + + it('refuses admin_full_access as the self-registration permission set', () => { + expect(() => AudienceConfigSchema.parse({ + posture: 'open', selfRegistrationPermissionSet: 'admin_full_access', + })).toThrow(/admin_full_access/); + }); + + it('refuses a permission set declared under invite_only (inert there)', () => { + expect(() => AudienceConfigSchema.parse({ + posture: 'invite_only', selfRegistrationPermissionSet: 'portal_user', + })).toThrow(/inert/); + }); + + it('rides AuthConfigSchema as the authorable `audience` key', () => { + const parsed = AuthConfigSchema.parse({ + audience: { posture: 'email_domain', allowedEmailDomains: ['acme.com'], selfRegistrationPermissionSet: 'portal_user' }, + }); + expect((parsed.audience as { posture?: string } | undefined)?.posture).toBe('email_domain'); + expect(() => AuthConfigSchema.parse({ audience: { posture: 'bogus' } })).toThrow(); + }); +}); diff --git a/packages/spec/src/system/auth-config.zod.ts b/packages/spec/src/system/auth-config.zod.ts index eb0e245fc5..6078a225fc 100644 --- a/packages/spec/src/system/auth-config.zod.ts +++ b/packages/spec/src/system/auth-config.zod.ts @@ -267,6 +267,182 @@ export const EmailVerificationConfigSchema = lazySchema(() => z.object({ ), }).optional().describe('Email verification options forwarded to better-auth')); +/** + * Audience posture — the ONE declaration answering "who may become a user of + * an app built on this environment" (#11739 / epic #11723). + * + * Before this existed, the answer was an emergent property of + * `emailAndPassword.disableSignUp` + `emailVerification` + `ssoOnlyMode` + + * plugin-auth's `membershipPolicy` + an implicit fallback permission set — + * five uncoordinated switches whose combined default was open + * self-registration with no email verification. Nobody chose that combination + * and no AI-authored app could declare otherwise. The vocabulary is CLOSED + * (maintainer ruling 2026-08-24, epic #11723) and the undeclared default is + * the safe end: `invite_only`. + * + * Follow the `MembershipPolicy` precedent (`@objectstack/plugin-auth`, + * reconcile-membership.ts): a runtime value list, an `isX()` entry guard, and + * a LOUD refusal of off-vocabulary values — never a silent coercion to a + * permissive branch (its docblock records the fail-open typo that made the + * precedent exist). + */ +export const AUDIENCE_POSTURES = ['invite_only', 'email_domain', 'open'] as const; + +export type AudiencePosture = (typeof AUDIENCE_POSTURES)[number]; + +/** Type guard over {@link AUDIENCE_POSTURES}. */ +export function isAudiencePosture(value: unknown): value is AudiencePosture { + return (AUDIENCE_POSTURES as readonly string[]).includes(value as string); +} + +/** + * Whether a posture PERMITS self-registration (someone becoming a user by + * their own act, with no per-user operator act). `invite_only` does not — + * admission there comes only from an explicit operator-side act (a pending + * invitation, admin create/import, SCIM provisioning, an operator-registered + * identity provider). + */ +export function audiencePermitsSelfRegistration(posture: AudiencePosture): boolean { + return posture === 'email_domain' || posture === 'open'; +} + +/** + * One declared email domain: bare lowercase-comparable hostname labels with at + * least one dot (`acme.com`, `mail.acme.com`). No scheme, no `@`, no leading + * dot, no wildcard — subdomains are NOT implied and need their own entries + * (the matching rules are pinned on the enforcement side, plugin-auth's + * `audience-posture.ts`). + */ +const AUDIENCE_EMAIL_DOMAIN_SHAPE = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/; + +/** + * The audience declaration. Every invariant here is mirrored — and enforced + * against the LIVE config — by plugin-auth's entry validation + * (`assertAudienceConfig`), because this schema guards the authoring surface + * while the runtime receives plain objects; the two must refuse the same + * shapes (ADR-0078: a declared-but-inert or open-but-unverified configuration + * is refused at declaration, never silently accepted). + */ +export const AudienceConfigSchema = lazySchema(() => z.object({ + /** + * Who may become a user of this environment's apps: + * + * - `invite_only` (default): self-registration is CLOSED. A user comes into + * existence only through an operator-side act — a pending invitation + * (which admits the invitee's own sign-up), admin create-user / bulk + * import, SCIM provisioning, or an operator-registered identity provider. + * - `email_domain`: self-registration is open ONLY to addresses whose domain + * is on {@link allowedEmailDomains}. Email verification is forced on. + * - `open`: anyone may self-register. Email verification is forced on. + */ + posture: z.enum(AUDIENCE_POSTURES).default('invite_only').describe( + 'Who may self-register into this environment: invite_only (default — operator acts only), ' + + 'email_domain (allowlisted email domains), or open (anyone). ' + + 'Any posture other than invite_only forces email verification on.', + ), + /** + * Required (non-empty) when `posture: 'email_domain'`; refused under any + * other posture (a domain list that gates nothing is the ADR-0078 + * "declared but inert" defect). Matching is case-insensitive and EXACT per + * entry — `mail.acme.com` is not admitted by `acme.com`. + */ + allowedEmailDomains: z.array( + z.string().regex( + AUDIENCE_EMAIL_DOMAIN_SHAPE, + 'a domain entry is a bare hostname with at least one dot (e.g. "acme.com") — no scheme, no "@", no wildcard', + ), + ).optional().describe( + 'Email domains admitted to self-register under posture email_domain (exact, case-insensitive match; ' + + 'subdomains need their own entries). Required non-empty for email_domain; refused under other postures.', + ), + /** + * The permission set a SELF-REGISTRANT receives, by `sys_permission_set` + * name. Required whenever the posture permits self-registration — the + * implicit `member_default` fallback is exactly the undeclared grant this + * card retires (declaring `member_default` explicitly is fine). Refused for + * `invite_only` (inert there: invited/provisioned users receive grants from + * their invitation placement or operator assignment, not from this key). + * The enforcement side refuses admission when the named set cannot be + * resolved (dangling declaration ⇒ nobody is admitted ungranted), and + * refuses `admin_full_access` at entry (a self-registrant must never + * receive the platform-admin set). + */ + selfRegistrationPermissionSet: z.string().min(1).optional().describe( + 'sys_permission_set name granted to each self-registrant. Required when posture is email_domain or open; ' + + 'refused for invite_only. admin_full_access is refused.', + ), +}).superRefine((value, ctx) => { + const posture = value.posture ?? 'invite_only'; + const permitsSelfRegistration = audiencePermitsSelfRegistration(posture); + if (posture === 'email_domain') { + if (!value.allowedEmailDomains || value.allowedEmailDomains.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['allowedEmailDomains'], + message: + "posture 'email_domain' requires a non-empty allowedEmailDomains list — " + + 'an email-domain gate with no domains admits nobody and reads as misconfiguration, not policy. ' + + "Declare the domains, or use posture 'invite_only'.", + }); + } + } else if (value.allowedEmailDomains !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['allowedEmailDomains'], + message: + `allowedEmailDomains is only read under posture 'email_domain' — under '${posture}' it would be ` + + 'declared but inert (ADR-0078), and an operator reading it would believe a wall exists that does not. ' + + 'Remove it, or set posture to email_domain.', + }); + } + if (value.allowedEmailDomains) { + const seen = new Set(); + for (let i = 0; i < value.allowedEmailDomains.length; i++) { + const lowered = value.allowedEmailDomains[i].toLowerCase(); + if (seen.has(lowered)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['allowedEmailDomains', i], + message: `duplicate domain entry '${value.allowedEmailDomains[i]}' (matching is case-insensitive)`, + }); + } + seen.add(lowered); + } + } + if (permitsSelfRegistration) { + if (!value.selfRegistrationPermissionSet) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['selfRegistrationPermissionSet'], + message: + `posture '${posture}' permits self-registration, so the permission set a self-registrant receives ` + + 'must be DECLARED (selfRegistrationPermissionSet) — the implicit member_default fallback is retired ' + + '(#11739; declaring member_default explicitly is allowed).', + }); + } else if (value.selfRegistrationPermissionSet === 'admin_full_access') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['selfRegistrationPermissionSet'], + message: + "selfRegistrationPermissionSet must not be 'admin_full_access' — an unscoped platform-admin grant " + + 'to every self-registrant is never a declarable audience.', + }); + } + } else if (value.selfRegistrationPermissionSet !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['selfRegistrationPermissionSet'], + message: + "selfRegistrationPermissionSet is only read under a posture that permits self-registration — under " + + "'invite_only' it is declared but inert (ADR-0078). Remove it, or open the posture deliberately.", + }); + } +})); + +export type AudienceConfig = z.input; +/** Post-parse shape of {@link AudienceConfig} — defaults applied, transforms run (ADR-0122). */ +export type AudienceConfigParsed = z.infer; + /** * Advanced / Low-level Better-Auth Options */ @@ -317,6 +493,23 @@ export const AuthConfigSchema = lazySchema(() => z.object({ oidcProviders: OidcProvidersConfigSchema, emailAndPassword: EmailAndPasswordConfigSchema, emailVerification: EmailVerificationConfigSchema, + /** + * Audience posture (#11739) — who may become a user of this environment's + * apps. Undeclared ⇒ `invite_only` (the safe default; maintainer ruling + * 2026-08-24 on epic #11723 — no legacy/undeclared limbo). + * + * Cross-field invariant, enforced by plugin-auth at config entry (this + * schema cannot see `emailAndPassword` from inside the sub-object, and the + * runtime receives plain objects): a posture that permits self-registration + * (`email_domain` / `open`) FORCES `emailAndPassword.requireEmailVerification` + * on — an explicit `requireEmailVerification: false` beside such a posture + * is refused loudly at boot (an unverified allowlisted-domain signup is + * colleague impersonation; it makes the domain gate decorative). + */ + audience: AudienceConfigSchema.optional().describe( + 'Audience posture: who may self-register into this environment ' + + '(invite_only — the default — | email_domain | open). See AudienceConfigSchema.', + ), advanced: AdvancedAuthConfigSchema, /** * SSO-only ("enforced") login mode. When `true`, the login UI hides the diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index 7d3b072c98..73ee81230b 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -671,11 +671,57 @@ export async function bootStack( return data.token; }; + /** + * [#11739 / #11767] Fixture users beyond the FIRST enter through the + * invitation carve-out. + * + * Since #11739 the platform's default audience posture is `invite_only`: + * only the bootstrap account (zero human users) self-registers freely, and + * every later self-serve sign-up needs a pending invitation, an allowlisted + * domain, or an `open` posture. The harness's `signUp` exists to mint the + * SECOND, THIRD… fixture identity, so it lands squarely on the wall. + * + * The invitation lane is deliberate rather than declaring `open` on the + * harness config — `open` and `email_domain` force `requireEmailVerification` + * on, which stops sign-up from minting the very token this helper returns, + * and it keeps the audience gate honestly ON the path (the carve-out is a + * real admission verdict, not a bypass). Same choice, same reasons, as + * plugin-auth's own `audience-gate-test-support.ts`. + * + * Best-effort: a stack whose engine or `sys_invitation` object is not + * reachable simply signs up without the row and lets the gate answer. + */ + const inviteForAudienceGate = async (email: string): Promise => { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const engine = await kernel.getServiceAsync('objectql'); + if (!engine || typeof engine.insert !== 'function') return; + await engine.insert( + 'sys_invitation', + { + id: `inv_verify_${Math.random().toString(36).slice(2, 10)}`, + email, + status: 'pending', + // A dedicated org id so fixtures counting THEIR invitations never + // see these rows. + organization_id: 'org_verify_audience_gate', + role: 'member', + inviter_id: 'usr_verify_audience_gate', + expires_at: new Date(Date.now() + 3_600_000), + }, + { context: { isSystem: true } }, + ); + } catch { + /* best-effort — the gate answers either way */ + } + }; + const signUp = async ( email: string, password = 'Member-Pass-123', name?: string, ): Promise => { + await inviteForAudienceGate(email); const res = await api('/auth/sign-up/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 52fc5d0f0c..e5e0a5e10f 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1276,6 +1276,16 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-auth/src/audience-posture.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-auth/src/audience-posture.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-auth/src/auth-manager.jwt-eddsa-fallback.test.ts", "verb": "delete",