Skip to content

feat(spec,auth)!: one declared audience posture for self-registration — invite_only|email_domain|open, default invite_only - #11767

Draft
os-zhuang wants to merge 4 commits into
mainfrom
claude/issue-11739-audience-posture
Draft

feat(spec,auth)!: one declared audience posture for self-registration — invite_only|email_domain|open, default invite_only#11767
os-zhuang wants to merge 4 commits into
mainfrom
claude/issue-11739-audience-posture

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#11739
Part of #11723

One declared audience posture answers "who may become a user of an environment's apps": a closed vocabulary invite_only | email_domain | open, default invite_only when undeclared (maintainer ruling on the epic, 2026-08-24 — no legacy/undeclared limbo), one decision owner on the creation path, and every invariant enforced where it is declared. Ships as a minor changeset per the launch-window rule (check:changeset-no-major), with the ADR-0087 semantic migration audience-posture-default-invite-only registered (the rest-requireauth-default-flip class: a secure-default flip no transform can decide).

Contract published for the blocked cloud consumer (objectstack-ai/cloud#1649)

  • Field shape and name — authorable key audience on AuthConfig (packages/spec/src/system/auth-config.zod.ts, exported from @objectstack/spec/system as AudienceConfigSchema / AudienceConfig):
    • audience.posture — enum, default invite_only.
    • audience.allowedEmailDomains — string array; required non-empty for email_domain, refused under any other posture (ADR-0078 inert-declaration refusal). Bare hostnames only, at least one dot, no wildcard.
    • audience.selfRegistrationPermissionSetsys_permission_setname; required whenever the posture permits self-registration (email_domain / open), refused under invite_only, and admin_full_access is always refused.
    • Authorable-surface manifests carry system/AuthConfig:audience and the system/AudienceConfig:* keys, with the invite_only default recorded in authorable-defaults.
  • Vocabulary valuesinvite_only, email_domain, open; runtime value list AUDIENCE_POSTURES plus guards isAudiencePosture() and audiencePermitsSelfRegistration() (all exported from @objectstack/spec/system).
  • Error codes (ADR-0112 ledger, @objectstack/plugin-auth entry) — all surfaced as HTTP 403 with the code in the body; browser OAuth flows redirect to the error URL carrying the same code:
  • Accessor cloud should read/writeAuthManager.getAudience() is the ONE live read (the getMembershipPolicy() pattern: the enforcement gate, the forced-verification wiring and the public config all read it). Cloud writes the posture by passing audience in the AuthPlugin/AuthManager config at kernel construction (AuthManagerOptions extends Partial of AuthConfig, so the key already flows), and may switch it live via AuthManager.applyConfigPatch({ audience: ... }) — the patch replaces the whole audience object and is validated as a merged result (an invalid patch throws and the standing config keeps ruling). Effective posture is observable per env on GET /api/v1/auth/config as features.audiencePosture (registered beside tenancyPosture in PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS).
  • Backfill note — since undeclared already IS invite_only, backfilling existing envs is semantically a no-op at the framework layer; writing an explicit audience: { posture: 'invite_only' } per env is still worthwhile for auditability, and the switch surface should write the full object (posture + domains + permission set) in one stroke.

What enforcement looks like

  • One decision owner: decideAudienceAdmission() in packages/plugins/plugin-auth/src/audience-posture.ts (pure — the test matrix drives it as a table). Wired at better-auth 1.7.1's own admission seam, user.validateUserInfo, which internalAdapter.createUser invokes for every creation path with a method-discriminated source (measured against the installed dist: email-password, oauth, sso-oidc/sso-saml, magic-link, email-otp, phone-number, anonymous, admin, scim), failing closed by vendor construction.
  • Creation classes (the SSO JIT determination the dispatch asked for, stated explicitly): admin and scim are operator class — never posture-gated; sso-oidc / sso-saml and oauth whose providerId is a configured oidcProviders entry (enterprise SSO, incl. the cloud platform IdP whose OP side already enforces app-assignment per D5.1) are provider class — never posture-gated, because registering an IdP is a platform-admin-only operator act (finding: the direct /sso/register endpoint's ADR-0024 before-hook admits org owners/admins — wider than the platform-admin posture #9653 landed on the /admin/sso/* bridges #10009) and the closed vocabulary could not otherwise express the standard enterprise combo "self-registration closed + IdP JIT on" (gating it would brick every SSO-enforced deployment's entire user base). Social-provider OAuth (socialProviders) is self-serve — a public IdP account is not an operator act — as is every unrecognized method (fail closed).
  • Invitation carve-out: a pending, unexpired sys_invitation for the address admits its self-serve sign-up under every posture (and trumps the domain list — an invited external contractor must not dead-end). Without it, invite_only would kill the invitation surface for brand-new users, since better-auth's accept-invitation requires an existing account.
  • Bootstrap bypass: zero users ⇒ admitted (mirrors the disableSignUp bypass — a fresh install must never lock its operator out).
  • Sign-up route raise: the same decision is additionally raised in the /sign-up/email before-hook because the vendor's anti-enumeration shield (shouldReturnGenericDuplicateResponse, dist/api/routes/sign-up.mjs lines 163/235) converts any 403 from the creation seam into a synthetic 200 whenever requireEmailVerification is on — measured: with posture open/email_domain the refusal came back 200 token null with a synthetic user. An audience refusal is deterministic per (posture, domain, invitation) and reveals nothing about any account, so surfacing it is not an enumeration oracle. The validateUserInfo gate stays wired for that route too; the decision is idempotent, so the two asks cannot disagree.
  • Invariants: any self-registration-permitting posture forces requireEmailVerification on (explicit false beside it is refused at config entry — constructor AND applyConfigPatch; getPublicConfig() mirrors the forced value so the advertised flag cannot disagree with the wired one); email_domain requires a non-empty, well-formed, duplicate-free domain list; the declared self-registrant permission set must resolve to an active sys_permission_set row at admission time — a dangling declaration refuses admission (AUTH_CONFIG_ERROR) rather than admitting an ungranted user, and an admitted self-registrant really receives the set (sys_user_permission_set row, org-scoped to the same target org the membership reconciler resolves; a failure to land it is reported at durability level with consequence + fix).
  • Pinned domain matching (the rules are stated and test-locked, per the card): domain = everything after the LAST at-sign, lowercased; case-insensitive exact per-entry equality; subdomains NOT implied in either direction; plus-tag local parts irrelevant; no punycode/IDN normalization (declare the form addresses carry).

Breaking behaviour and its migration

FROM: undeclared config meant open email/password self-registration with no verification, self-registrants implicitly on member_default. TO: undeclared IS invite_only. One-line fix for deployments that mean to stay open is in the changeset and in the registered ADR-0087 entry (declare audience.posture: 'open' plus selfRegistrationPermissionSet). Operator lanes (admin create/import, SCIM, enterprise IdP JIT, invitations) are unaffected under every posture. In-repo fixture fallout was triaged accordingly: harness suites now create non-first fixture users through the invitation carve-out (audience-gate-test-support.ts) — the real admission lane, chosen over an open fixture posture because open forces verification and would stop sign-up from minting the sessions those suites exercise.

File surface — declared expansions beyond the card's named globs

The card named packages/spec/src/system/** (+ authorable-surface manifests), packages/plugins/plugin-auth/src/**, packages/platform-objects/src/** (unused — the permission-set predicate landed in plugin-auth, where the enforcement point lives). Also touched, each mechanically forced and declared here rather than silent:

  • packages/spec/src/api/error-code-ledger.zod.ts — the card requires the two codes REGISTERED; the ledger is the registration.
  • packages/spec/src/kernel/public-auth-features.tsaudiencePosture non-flag booking; the dispatch prompt names this file (the fix(platform-objects,spec): surface the email-invite entry on the org page default Members tab #11562 motion).
  • packages/spec/src/migrations/entries/semantic/18.audience-posture-default-invite-only.ts + regenerated registry.ts — the ADR-0087 disposition the declared-breaking changeset must carry.
  • Regenerated artifacts: packages/spec/{api-surface,authorable-surface,authorable-defaults,export-origins,json-schema.manifest}/system.json, content/docs/references/**, docs/audits/2026-07-unknown-key-strictness-ledger.counts.md, scripts/engine-double-contract.pinned.json (coverage ledger, --write after the new pinned double).
  • .changeset/audience-posture-invite-only-default.md.

Verification (all at the head this PR points at, 38e5fea48; tree clean)

Every heavy command ran through scripts/pm/os-verify-lock.sh, which printed its lock-unusable verdict on this macOS host (no util-linux flock): declared narrowing — verification ran UNLOCKED, no serialization guarantee held. Verdict lines quoted from the runs:

  • pnpm --filter @objectstack/plugin-auth testTest Files 75 passed (75) / Tests 1559 passed (1559); union re-run at 38e5fea48 ended VERDICT command-exit 0 · UNLOCKED (declared).
  • pnpm --filter @objectstack/spec typecheck + targeted spec suites (src/system src/api src/kernel src/migrations + the type-alias pin) — Test Files 128 passed (128) / Tests 3597 passed (3597).
  • pnpm --filter @objectstack/spec check:generated all 14 artifact gates (api-surface, authorable-surface, docs, spec-changes, upgrade-guide, migration-registry, strictness-ledger included).
  • Full-workspace pnpm build --concurrency=2 (129 tasks), pnpm typecheck --concurrency=2Tasks: 129 successful, 129 total, and full pnpm lint (eslint . --no-inline-config, exit 0 in 32s — full run, no narrowing).
  • The 40 path-derived gates from node scripts/pm/dispatch-gates.mjs (derivation stderr named this tree at 38e5fea48): 39 green. One environmental red, declared:pnpm check:objectui-changeset's self-test cannot run on this host (its bump-objectui.sh uses mapfile, absent from macOS bash 3.2 — status 127 in the self-test harness, not a verdict about this diff; the gate matched only because a changeset file was added). CI runs it on Linux.
  • check-engine-double-contract — green, 385 (file, verb) row(s) held by the RETAINED ledger (+2 rows for the new double); check-error-code-casing, check-nul-bytes — green; check-adr-0087-registration1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.

CI convergence is the PM's read per the dispatch contract; gate states at report time are as above.


Generated by Claude Code

os-zhuangand others added 4 commits August 24, 2026 23:04
…mail_domain|open, default invite_only
Closed vocabulary + runtime value list + type guard (MembershipPolicy
precedent), completeness predicates in the schema (email_domain requires
non-empty domains; self-registration-permitting postures require the
declared permission set; inert declarations refused per ADR-0078), two
registered error codes (SELF_REGISTRATION_CLOSED, EMAIL_DOMAIN_NOT_ALLOWED)
and the audiencePosture non-flag public-config key. Artifacts regenerated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ssion seam
One decision owner (decideAudienceAdmission) wired at user.validateUserInfo
for every creation path, plus the sign-up before-hook raise that the
vendor's anti-enumeration shield forces (a 403 from the creation seam is
converted to a synthetic 200 when requireEmailVerification is on — measured
on dist/api/routes/sign-up.mjs:163,235). Pending invitations admit under
every posture; enterprise-IdP JIT is provider-class; self-registrants
receive the DECLARED permission set via sys_user_permission_set or are not
admitted at all.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nvite_only default
New audience-posture suite (pure decision matrix, entry refusals, wire
outcomes incl. the landed permission-set grant). Existing harness suites
create their non-first fixture users through the invitation carve-out
(audience-gate-test-support) — the real admission lane under the new
default, chosen over an open posture because open forces email
verification and would stop sign-up from minting the sessions these
suites exercise.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ture default flip
Registered audience-posture-default-invite-only (the
rest-requireauth-default-flip class: a secure-default flip no transform
can decide); pinned ledger regenerated for the new engine double.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant

@os-zhuang