Skip to content

feat(plugin-auth): multi-org Create User joins the administrator's current organization - #25626

Merged
os-zhuang merged 15 commits into
mainfrom
claude/trusting-thompson-xvcx6q
Oct 25, 2026
Merged

os-zhuang merged 15 commits into
mainfrom
claude/trusting-thompson-xvcx6q

Conversation

@baozhoutao

@baozhoutao baozhoutao commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Refs #25625 (close it by hand once this merges: the branch name is fixed by the session, so it cannot carry the claude/issue-N-… claim the closing-target guard reads)

What changes for the admin

In multi-org mode, Users → Create User adds the new user to the organization the administrator is working in, so the follow-up Add Member is no longer needed. Mainstream admin consoles behave the same way.

  • There is no organization choice. To add a user to another organization, switch to it first.
  • An administrator with no current organization is refused with "switch to an organization first". The route never creates a member-less account.
  • Single-org creation, self sign-up, bulk import, SSO sign-in and invitations are unchanged.

Reworked per the maintainer's ruling 「25626 按当前组织」 (#25625 comment 6386597064, confirmed by the maintainer in session): 「当前组织的管理员增加用户就应该加入这个组织」. The earlier Organization picker, the organizationId request parameter, its multiOrgEnabled gated-input row and the four translations are removed. @objectstack/spec and @objectstack/platform-objects are byte-identical to main.

How

  • Under a walled posture (tenancy.isolationActive, the predicate behind features.multiOrgEnabled), POST /api/v1/auth/admin/create-user reads the caller's activeOrganizationId from their session before any account exists:
    • None: 400 NO_ACTIVE_ORGANIZATION, nothing created.
    • Otherwise: the new user joins it as member through better-auth's own addMember (the Add Member act), inside the account's transaction. A refused join returns the vendor's status and code, and no user is created. An existing membership is kept (membershipCreated: false).
  • membershipPolicy: 'invite-only' still joins. Creating a user inside your organization is an explicit act (ADR-0093 D1).
  • A walled server without the organization plugin or a unit of work answers 501 before any account exists.
  • ADR-0093: the D3 amendment records this one rule. The reconciler and every other creation path still bind nothing in multi-org mode.
  • The docs (authentication.mdx, tenancy-modes.mdx) are updated. The changeset is BREAKING (narrowing): FROM a member-less account TO 400 NO_ACTIVE_ORGANIZATION for an administrator with no current organization. The fix is to switch to an organization first.

Verification

  • Unit (admin-user-endpoints.test.ts) covers:

    • joining the current organization, where a body organizationId chooses nothing
    • invite-only
    • no current organization (400, no account)
    • an existing membership
    • vendor refusal with rollback
    • the 501s
    • single-org and degraded postures, unchanged
    • the dialog asking for no organization

    The plugin-auth and platform-objects suites pass.

  • Dogfood: create-user-organization-join.dogfood.test.ts runs on a real isolated stack:

    • J1: the user joins the admin's current organization, even when the body names another.
    • J2: with no current organization the request is refused and no sys_user row exists.

    The existing create-user, permission-set, reconciler, audit, self-registration and sign-up dogfoods pass (44 tests).

  • pnpm lint, typecheck, check:i18n, check:adr-0087-registration, check:engine-double-contract, check:role-word, check:doc-authoring and check:adr-anchors pass.

Landing

This diff touches docs/adr/** (Tier H). It lands only after an authorized approving review. No AI seat queues it before that.

Clause-②: yes (narrowing)
Metadata-protocol: none


Generated by Claude Code

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-auth, @objectstack/spec, touching 15 documentable anchor(s).

16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 6679644a1a56b552d6864b125900221823ec78b3.

⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 149 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6679644a1a56b552d6864b125900221823ec78b3 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 415030e3de41c9989e21ff8c013113d7a0d04d09 — the merge of head 366391c7697a07047485df3dd009e907c6b26247 into base 6679644a1a56b552d6864b125900221823ec78b3, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 415030e3de41c9989e21ff8c013113d7a0d04d09 && git checkout 415030e3de41c9989e21ff8c013113d7a0d04d09
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6679644a1a56b552d6864b125900221823ec78b3 366391c7697a07047485df3dd009e907c6b26247 && git checkout -B drift-repro 6679644a1a56b552d6864b125900221823ec78b3 && git merge --no-ff 366391c7697a07047485df3dd009e907c6b26247

node scripts/docs-audit/affected-docs.mjs --json 6679644a1a56b552d6864b125900221823ec78b3

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6679644a1a56b552d6864b125900221823ec78b3 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…odes

The membership callout said Create User writes no membership under a
walled posture; it now joins the chosen organization or the
administrator's current one, so both pages say so and the create-user
section documents organizationId and the join table.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VEz8GXcBLFWiasohtBhYgR
…tract; reword a docs line

The two new engine fakes in admin-user-endpoints.test.ts now open update()
with assertEngineUpdateDispatch, as check:engine-double-contract requires,
and its pinned ledger records the grown coverage. The authentication page
no longer adds role-word occurrences (ADR-0090 D3).

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VEz8GXcBLFWiasohtBhYgR
This was referenced Oct 24, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor

Heads-up from domain:spec seat 2 · 2026-10-24T14:37Z · ⛔ not a claim, no label change.

PR #25637 (#24814) is in the merge queue with auto-merge on. It edits the same packages/platform-objects/src/identity/sys-user.object.ts and the four platform-objects locale bundles (en / es-ES / ja-JP / zh-CN *.objects.generated.ts) as this PR. The cross-account contract review 6385469184 on #25637 notes that whichever lands second merges origin/main and regenerates the bundles with check-i18n-bundles --write, never by hand. If #25637 merges first, that step falls to this PR.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Maintainer instruction · 「25626 按当前组织」 · 2026-10-24T16:22:33Z · record on #25625 comment 6386597064

This PR is not approved in its current shape. Rework per the record: multi-org Create User joins the administrator's current organization as member, always. Remove the Organization picker, the organizationId request parameter and its public-auth-features gated-input row, the four translations and the docs/changeset text about choosing an organization. invite-only still joins (the administrator's act is explicit). An administrator with no current organization is refused with "switch to an organization first", not given a member-less user. Single-org and every other creation path unchanged; the join stays better-auth addMember inside the account's transaction. The ADR-0093 D3 amendment shrinks to that one rule; still Tier H, the maintainer approves the reworked head. If #25637 lands first, merge origin/main and regenerate the i18n bundles with check-i18n-bundles --write (seat note 6385491112).

Maintainer's words: 「单库多组织的时候,浏览器可以切换组织啊,当前始终会属于一个组织啊,当前组织的管理员增加用户就应该加入这个组织啊,怎么会每次增加的时候要选择。你再想想主流系统是怎么做的」


Generated by Claude Code

…rrent organization, no picker

Reworked per the maintainer's ruling: under a walled posture the new user
always joins the calling administrator's current organization as member
(better-auth addMember, inside the account's transaction); invite-only
still joins; an administrator with no current organization is refused
400 NO_ACTIVE_ORGANIZATION. The Organization picker, the organizationId
body parameter, its multiOrgEnabled gated-input row and the four
translations are removed; the ADR-0093 D3 amendment, docs and changeset
shrink to that one rule.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VEz8GXcBLFWiasohtBhYgR
@baozhoutao baozhoutao changed the title feat(plugin-auth): multi-org Create User joins an organization in the same step, defaulting to the admin's current one feat(plugin-auth): multi-org Create User joins the administrator's current organization Oct 25, 2026
…edger; port the flows.screens liveness repoint

check:dispatcher-error-vocabulary refused the create-user refusal's code
as unregistered: it is now in @objectstack/spec's ledger under
@objectstack/plugin-auth, with the generated reference pages regenerated.

Spec property liveness was red on main itself (the translation/flows.screens
row cites engine.ts, which no longer names the key after the resume-body
extraction). The same change as the open fix PR 25751 is ported here so
this PR is green; it no-ops once main carries that PR.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VEz8GXcBLFWiasohtBhYgR

Copy link
Copy Markdown
Contributor Author

CI note: Spec property liveness is red on main itself, not because of this PR. The translation/flows.screens ledger row still cites service-automation/src/engine.ts, which stopped naming the key when #25734 extracted the resume body. #25751 fixes it, and 48cfe296 ports the same liveness/translation.json change here so this PR goes green. The port becomes a no-op once #25751 lands on main.

The same push registers NO_ACTIVE_ORGANIZATION in the error-code ledger, which clears Dispatcher error-code vocabulary guard. That failure was this PR's own.


Generated by Claude Code

…e budget fix

Test Core (8/8) failed twice on this PR in plugin-pinyin-search's
boot-reconcile.integration.test.ts, as it does on other open PRs: the
fixture seeds 2,500 rows one awaited create at a time and the suite
overruns vitest's 5 s default under CI load. The same change as the open
fix PR 25775 is ported here; it no-ops once main carries that PR.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VEz8GXcBLFWiasohtBhYgR

Copy link
Copy Markdown
Contributor Author

CI note: Test Core (8/8) failed twice in plugin-pinyin-search (boot-reconcile.integration.test.ts), the original run plus one re-run. The cause is not this PR, which does not touch that package, and the suite passes locally (39/39, and 6 consecutive runs of that file). #25745, #25757 and #25751 fail the same way. Per #25749, the fixture seeds 2,500 rows one awaited create at a time and overruns vitest's 5 s default under CI load. #25775 fixes it, and fc3257d2 ports the same test-only change here (batched bulkCreate, a 30 s suite budget). The port becomes a no-op once #25775 lands on main.


Generated by Claude Code

@os-zhuang
os-zhuang added this pull request to the merge queue Oct 25, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 25, 2026
…son-xvcx6q

# Conflicts:
#	packages/spec/liveness/translation.json
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants