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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions