Skip to content

feat(spec,auth)!: one declared audience posture for who may self-register into an app — invite_only|email_domain|open, default invite_only #11739

Description

@os-zhuang

Part of #11723 (epic — the ruling, the four-axis record, and the public correction of the migration shape all live there; read it first).

Why this exists (measured, 2026-08-24)

"Who may become a user of an app built on ObjectStack" is not declarable today. It is an emergent property of emailAndPassword.disableSignUp + emailVerification + ssoOnlyMode (spec AuthConfig) + plugin-auth's membershipPolicy + an implicit fallback permission set. Nobody chose that combination, and its default is open self-registration with no email verification, with reconcileMembership (policy default auto) auto-binding each registrant into the env's org. There is no email-domain allowlist anywhere in the repo (zero hits; neighbouring-term counter-check passes — disableSignUp hits in the same files). The AI build agent's blueprint has no auth surface at all, so every AI-generated app inherits that default and the AI is structurally unable to declare otherwise.

Deliverable — ONE declared audience posture, enforced in ONE place

Vocabulary (ruled, closed):invite_only | email_domain | open. Default when undeclared: invite_only. Follow the existing MembershipPolicy precedent in this package for the shape: a runtime value list, isX() entry validation, and a loud refusal of off-vocabulary values (never coerce to a permissive branch — that exact fail-open is why that precedent exists).

Invariants to enforce (this is the contract-tightening half — declared = enforced):

  1. Posture !== invite_onlyemail verification is forced on. A configuration that opens self-registration while disabling verification must be refused loudly at parse/boot, not silently accepted: an unverified allowlisted-domain signup is impersonation of a colleague, and it makes the domain gate decorative.
  2. Posture email_domain ⇒ a non-empty domain list is required (completeness predicate; ADR-0078 family — "declared but inert" is the defect class this whole card is closing).
  3. Any posture that permits self-registration ⇒ the permission set a self-registrant receives must be explicitly declared, not left to the implicit member_default fallback. Refuse at the same predicate if it is missing or names a non-existent set.
  4. Enforcement lives at one point on the signup path, with distinct, registered error codes for: self-registration closed, email domain not allowed. Do not spread the check across call sites.

Placement judgement (yours, argue it in the report): whether the posture belongs on AuthConfig in packages/spec/src/system/auth-config.zod.ts as one object (posture + domains + permission set) or as a sibling declaration. It must be authorable (it will need entries in the authorable-surface manifests) and readable by plugin-auth through a single accessor — the getMembershipPolicy() pattern in auth-manager.ts is the precedent for "one source both paths read", and it exists because a captured constructor option went stale against a live setting.

Breaking change posture (ruled): ship as a minor changeset with a loud release note — the repo is inside its launch window and scripts/check-changeset-no-major.mjs refuses any new major (all publishable packages are in the Changesets fixed group). ⛔ Do not introduce a major; if you believe one is unavoidable, STOP and report needs_decision.

Tests: the posture matrix (each vocabulary value × email signup / SSO JIT / admin-create); off-vocabulary value refused loudly; verification-forced invariant; empty-domain-list refusal; missing/dangling permission-set refusal; domain matching (case-insensitivity, subdomains, +tag addresses — state the ruling you implement and pin it); undeclared config resolves to invite_only; error codes distinct and registered.

Non-goals (ruled on the epic)

Token join links, approval queues, per-app (as opposed to per-environment) postures, SCIM interplay, custom onboarding flows, and — explicitly dropped in the epic's public correction — any "undeclared = legacy open" limbo or warning-banner remediation surface.

Consumer to unblock

cloud sub-issue (new-env provisioning + backfill of existing envs + posture switch) carries Blocked-by: pointing here. Your PR body MUST publish, in a dedicated section: the final field shape/name, the vocabulary values, the error codes, and the accessor cloud should read/write — that card consumes them verbatim.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions