Skip to content

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

Open
baozhoutao wants to merge 14 commits into
mainfrom
claude/trusting-thompson-xvcx6q
Open

baozhoutao wants to merge 14 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). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/liveness/translation.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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 5612dd931be8f982847ac83b021ce56daa0b23d9.

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/liveness/translation.json) — pages documenting those are invisible to this run
  • 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 5612dd931be8f982847ac83b021ce56daa0b23d9 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f457b032f759a7b73740cbdb7e61b9d05be645a5 — the merge of head fc3257d2dec870b14e06360f484611dea128c36f into base 5612dd931be8f982847ac83b021ce56daa0b23d9, 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 f457b032f759a7b73740cbdb7e61b9d05be645a5 && git checkout f457b032f759a7b73740cbdb7e61b9d05be645a5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5612dd931be8f982847ac83b021ce56daa0b23d9 fc3257d2dec870b14e06360f484611dea128c36f && git checkout -B drift-repro 5612dd931be8f982847ac83b021ce56daa0b23d9 && git merge --no-ff fc3257d2dec870b14e06360f484611dea128c36f

node scripts/docs-audit/affected-docs.mjs --json 5612dd931be8f982847ac83b021ce56daa0b23d9

⚠️ 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 5612dd931be8f982847ac83b021ce56daa0b23d9 → 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

This branch has not been deployed

No deployments
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