Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/audience-posture-invite-only-default.md
Original file line numberDiff line numberDiff line change
@@ -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`).

<!-- adr-0087: registered audience-posture-default-invite-only -->
4 changes: 3 additions & 1 deletion content/docs/references/api/contract.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`. |
Expand DownExpand Up@@ -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`
Expand DownExpand Up@@ -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`
Expand Down
2 changes: 2 additions & 0 deletions content/docs/references/api/error-code-ledger.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`
Expand DownExpand Up@@ -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`
Expand Down
10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line numberDiff line numberDiff line change
@@ -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/. */}
Expand DownExpand Up@@ -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 |

---

Expand DownExpand Up@@ -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` |
Expand Down
18 changes: 16 additions & 2 deletions content/docs/references/system/auth-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
Expand All@@ -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
Expand All@@ -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 |
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -264,4 +264,4 @@ directory rather than per file.
| `kernel/` | 296 |
| `qa/` | 6 |
| `shared/` | 20 |
| `system/` | 360 |
| `system/` | 361 |
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand All@@ -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(
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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 });
Expand Down
Loading
Loading