Found while an app built on the platform declared membershipPolicy: 'invite-only' at author time (objectstack-ai/ats#38). Measured on @objectstack/cli 17.3.0 and verified against src here.
Filing this as a contract question, not as a confirmed bug — the behaviour may well be intended. What is not defensible either way is that it is undocumented, so a reader of getMembershipPolicy()'s own docblock would conclude the opposite of what the code does.
The accounting
packages/plugins/plugin-auth/src/auth-manager.ts:3840-3855:
ADR-0093 D1 — the deployment's membership policy as it stands right now.
The ONE source both membership paths read (#5152):
- sign-up: the reconciler composed into
user.create.after (below);
- backfill:
AuthPlugin's ADR-0093 D6 pass over pre-existing member-less users …
Read the policy through here, never off a captured option, so a settings change reaches both without a restart.
Two paths are enumerated. There are three production call sites' worth of membership writing, and the third is not in the list.
The third path
packages/plugins/plugin-auth/src/admin-user-endpoints.ts:330-332, inside bindUserToSoleOrganization():
const result = await reconcileMembership(engine, userId, {
policy: 'auto',
resolveTargetOrg: () => (tenancy ? tenancy.defaultOrgId() : resolveDefaultOrgId(engine)),
getMembershipPolicy() is never consulted. Grepping the package, the only two non-test callers of reconcileMembership are auth-manager.ts:3890 — which reads the accessor, with :3895 noting "the single source the backfill path reads too" — and this one, which does not.
Measured
On an app declaring invite-only (and with the declaration verified in effect: the ADR-0093 D6 backfill bound nobody, sys_member held only genuinely-seeded rows):
POST /api/v1/auth/admin/create-user → HTTP 200, "membershipCreated": true
and the new account's session carries the Default Organization.
Why it may be correct, and why it should say so
An operator explicitly creating an account through an admin endpoint arguably is the invitation that invite-only is named for. That reading is reasonable — arguably more reasonable than the alternative.
But nothing records it. There is no comment at the call site and no test in the package mentioning invite-only alongside the admin endpoint, in a function whose immediately preceding comment cites "ADR-0093 D3 — mode-aware target resolution" and explains a null return in multi mode. Against that density, an undocumented literal 'auto' reads as an oversight rather than a decision — and an operator who set the policy specifically to keep a shared organization identity off their users will not expect an admin-created account to acquire one.
The failure direction matters too: this path grants a membership the policy was set to withhold, so it fails open rather than closed.
Either resolution closes it
- If it is deliberate — one comment at
admin-user-endpoints.ts:330 saying so, and a line in getMembershipPolicy()'s docblock making the accounting three paths (two read the source; admin-create is the invitation and binds unconditionally). A test pinning "admin-create binds under invite-only" would keep it that way.
- If it is not — read
getMembershipPolicy() here like the other caller, and keep whatever override the endpoint needs explicit at its own layer rather than as a literal.
Either is small. The present state is the only one that cannot be right, because the docblock and the code disagree about how many paths there are.
Found while an app built on the platform declared
membershipPolicy: 'invite-only'at author time (objectstack-ai/ats#38). Measured on@objectstack/cli17.3.0 and verified againstsrchere.Filing this as a contract question, not as a confirmed bug — the behaviour may well be intended. What is not defensible either way is that it is undocumented, so a reader of
getMembershipPolicy()'s own docblock would conclude the opposite of what the code does.The accounting
packages/plugins/plugin-auth/src/auth-manager.ts:3840-3855:Two paths are enumerated. There are three production call sites' worth of membership writing, and the third is not in the list.
The third path
packages/plugins/plugin-auth/src/admin-user-endpoints.ts:330-332, insidebindUserToSoleOrganization():getMembershipPolicy()is never consulted. Grepping the package, the only two non-test callers ofreconcileMembershipareauth-manager.ts:3890— which reads the accessor, with:3895noting "the single source the backfill path reads too" — and this one, which does not.Measured
On an app declaring
invite-only(and with the declaration verified in effect: the ADR-0093 D6 backfill bound nobody,sys_memberheld only genuinely-seeded rows):and the new account's session carries the Default Organization.
Why it may be correct, and why it should say so
An operator explicitly creating an account through an admin endpoint arguably is the invitation that
invite-onlyis named for. That reading is reasonable — arguably more reasonable than the alternative.But nothing records it. There is no comment at the call site and no test in the package mentioning
invite-onlyalongside the admin endpoint, in a function whose immediately preceding comment cites "ADR-0093 D3 — mode-aware target resolution" and explains anullreturn in multi mode. Against that density, an undocumented literal'auto'reads as an oversight rather than a decision — and an operator who set the policy specifically to keep a shared organization identity off their users will not expect an admin-created account to acquire one.The failure direction matters too: this path grants a membership the policy was set to withhold, so it fails open rather than closed.
Either resolution closes it
admin-user-endpoints.ts:330saying so, and a line ingetMembershipPolicy()'s docblock making the accounting three paths (two read the source; admin-create is the invitation and binds unconditionally). A test pinning "admin-create binds underinvite-only" would keep it that way.getMembershipPolicy()here like the other caller, and keep whatever override the endpoint needs explicit at its own layer rather than as a literal.Either is small. The present state is the only one that cannot be right, because the docblock and the code disagree about how many paths there are.