Uh oh!
There was an error while loading. Please reload this page.
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
Draft
Conversation
…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>
This was referenced Aug 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, defaultinvite_onlywhen 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 aminorchangeset per the launch-window rule (check:changeset-no-major), with the ADR-0087 semantic migrationaudience-posture-default-invite-onlyregistered (therest-requireauth-default-flipclass: a secure-default flip no transform can decide).Contract published for the blocked cloud consumer (objectstack-ai/cloud#1649)
audienceonAuthConfig(packages/spec/src/system/auth-config.zod.ts, exported from@objectstack/spec/systemasAudienceConfigSchema/AudienceConfig):audience.posture— enum, defaultinvite_only.audience.allowedEmailDomains— string array; required non-empty foremail_domain, refused under any other posture (ADR-0078 inert-declaration refusal). Bare hostnames only, at least one dot, no wildcard.audience.selfRegistrationPermissionSet—sys_permission_setname; required whenever the posture permits self-registration (email_domain/open), refused underinvite_only, andadmin_full_accessis always refused.system/AuthConfig:audienceand thesystem/AudienceConfig:*keys, with theinvite_onlydefault recorded inauthorable-defaults.invite_only,email_domain,open; runtime value listAUDIENCE_POSTURESplus guardsisAudiencePosture()andaudiencePermitsSelfRegistration()(all exported from@objectstack/spec/system).@objectstack/plugin-authentry) — all surfaced as HTTP 403 with the code in the body; browser OAuth flows redirect to the error URL carrying the same code:SELF_REGISTRATION_CLOSED— a valid posture closed self-registration (no pending invitation for the address).EMAIL_DOMAIN_NOT_ALLOWED— postureemail_domain, address's domain off the allowlist.AUTH_CONFIG_ERROR(pre-registered) — the audience configuration itself is unusable at admission time (off-vocabulary posture smuggled past entry, or the declared permission set cannot be resolved). Deliberately distinct from "policy said no" (the finding(auth): reconcileMembership 与 backfillMemberships 对「词表外的 policy」判断相反 —— 一个 fail-open,一个 fail-safe #5205 lesson).AuthManager.getAudience()is the ONE live read (thegetMembershipPolicy()pattern: the enforcement gate, the forced-verification wiring and the public config all read it). Cloud writes the posture by passingaudiencein the AuthPlugin/AuthManager config at kernel construction (AuthManagerOptions extends Partial of AuthConfig, so the key already flows), and may switch it live viaAuthManager.applyConfigPatch({ audience: ... })— the patch replaces the wholeaudienceobject and is validated as a merged result (an invalid patch throws and the standing config keeps ruling). Effective posture is observable per env onGET /api/v1/auth/configasfeatures.audiencePosture(registered besidetenancyPostureinPUBLIC_AUTH_CONFIG_NON_FLAG_KEYS).invite_only, backfilling existing envs is semantically a no-op at the framework layer; writing an explicitaudience: { 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
decideAudienceAdmission()inpackages/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, whichinternalAdapter.createUserinvokes 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.adminandscimare operator class — never posture-gated;sso-oidc/sso-samlandoauthwhose providerId is a configuredoidcProvidersentry (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).sys_invitationfor 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_onlywould kill the invitation surface for brand-new users, since better-auth's accept-invitation requires an existing account.disableSignUpbypass — a fresh install must never lock its operator out)./sign-up/emailbefore-hook because the vendor's anti-enumeration shield (shouldReturnGenericDuplicateResponse,dist/api/routes/sign-up.mjslines 163/235) converts any 403 from the creation seam into a synthetic 200 wheneverrequireEmailVerificationis on — measured: with postureopen/email_domainthe refusal came back200 token nullwith 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. ThevalidateUserInfogate stays wired for that route too; the decision is idempotent, so the two asks cannot disagree.requireEmailVerificationon (explicitfalsebeside it is refused at config entry — constructor ANDapplyConfigPatch;getPublicConfig()mirrors the forced value so the advertised flag cannot disagree with the wired one);email_domainrequires a non-empty, well-formed, duplicate-free domain list; the declared self-registrant permission set must resolve to an activesys_permission_setrow 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_setrow, org-scoped to the same target org the membership reconciler resolves; a failure to land it is reported at durability level with consequence + fix).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 ISinvite_only. One-line fix for deployments that mean to stay open is in the changeset and in the registered ADR-0087 entry (declareaudience.posture: 'open'plusselfRegistrationPermissionSet). 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 anopenfixture posture becauseopenforces 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.ts—audiencePosturenon-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+ regeneratedregistry.ts— the ADR-0087 disposition the declared-breaking changeset must carry.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,--writeafter 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-linuxflock): declared narrowing — verification ran UNLOCKED, no serialization guarantee held. Verdict lines quoted from the runs:pnpm --filter @objectstack/plugin-auth test—Test Files 75 passed (75)/Tests 1559 passed (1559); union re-run at38e5fea48endedVERDICT 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).pnpm build --concurrency=2(129 tasks),pnpm typecheck --concurrency=2—Tasks: 129 successful, 129 total, and fullpnpm lint(eslint . --no-inline-config, exit 0 in 32s — full run, no narrowing).node scripts/pm/dispatch-gates.mjs(derivation stderr named this tree at38e5fea48): 39 green. One environmental red, declared:pnpm check:objectui-changeset's self-test cannot run on this host (itsbump-objectui.shusesmapfile, 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-registration—1 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