Skip to content

docs: document POST /organization/add-member — the platform-admin-only path to attach an existing user - #10535

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-10050-organization-add-member-docs
Aug 21, 2026
Merged

docs: document POST /organization/add-member — the platform-admin-only path to attach an existing user#10535
os-zhuang merged 1 commit into
mainfrom
claude/issue-10050-organization-add-member-docs

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes: #10050

Documents POST /api/v1/auth/organization/add-member, mounted by #9941 / PR #10049 and never written up. Docs-only — two files under content/docs, no source changes.

What the route actually does — verified, not inferred

Every claim below was read off the implementation (packages/plugins/plugin-auth/src/organization-add-member.ts, the mount at auth-plugin.ts:2122), its test (organization-add-member.test.ts), or the installed vendor source. The vendor legs were measured on better-auth 1.7.1, dist/plugins/organization/routes/crud-members.mjs.

  • It is an ObjectStack mount, not a better-auth route. addMember is built with createAuthEndpoint({ method: "POST", … })no path argument — while every sibling in the same module (/organization/remove-member, /organization/list-members, /organization/leave, …) passes one. So the catch-all never mounted it, the vendor's docs do not list it, and enumerating BETTER_AUTH_MOUNTED_SURFACE does not reach it. auth-plugin.ts mounts the URL ahead of the catch-all.
  • Admit set is platform admin only. The shared ADR-0068 gate runs before the handler. Pinned in the test on the real wiring: anonymous → 401 UNAUTHENTICATED, plain member → 403, and an organization owner403 PERMISSION_DENIED. The org-owner refusal is the surprising one and is now stated as deliberate.
  • Ordering is identity → capability (501 when the organization plugin is off) → body (400) → the vendor's verdicts. Both the anonymous-before-body and capability-before-body legs are pinned by tests.
  • Refusals are documented with code and status per ADR-0112. The vendor's four are forwarded verbatim by mapAuthApiError, which preserves the vendor's status — so USER_NOT_FOUND / USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION / NO_ACTIVE_ORGANIZATION are 400 (APIError.from("BAD_REQUEST", …)) and ORGANIZATION_MEMBERSHIP_LIMIT_REACHED is 403 (APIError.from("FORBIDDEN", …), default limit 100). The membership-limit row is read from vendor source, not from a test — no test exercises it.
  • organizationId omitted falls back to the caller's active organization: const orgId = ctx.body.organizationId || session?.session.activeOrganizationId. Pinned by the NO_ACTIVE_ORGANIZATION test.
  • ⚠️ teamId does NOT fall back to an active team. const teamId = "teamId" in ctx.body ? ctx.body.teamId : void 0 — omitted means no team, full stop. Three source comments claim otherwise; the docs here state the asymmetry correctly and the comments are filed as finding: three source comments claim teamId on organization/add-member defaults to the caller's active team — better-auth 1.7.1 has no such fallback #10532.

Where it went, and why there

  • content/docs/permissions/authentication.mdx — a new #### Attaching an existing user to an organization under the existing ### Organizations (Multi-Tenant) section (beside the invitation flow it is the alternative to), plus a #### Organization Membership entry in the ## API Reference endpoint list where the sibling admin routes are already listed.
  • content/docs/deployment/tenancy-modes.mdx — a callout in ## Membership: how new users join an organization. That page already named add-member three times without ever saying what URL it is, which is a good part of how the route stayed invisible while looking discussed.

Location was established from the tree: authentication.mdx is the only non-release page that documents /api/v1/auth/admin/* routes, and tenancy-modes.mdx is the only one that discusses attaching users under the org wall.

⚠️ One thing this PR could not do

The route's body carries better-auth's role field, and it has no snake_case alias — the wire name is role and nothing else. check:role-word is a per-file exact ratchet over content/docs with no exemption mechanism, so no file in the corpus can gain that word, and expanding the baseline is marked ⛔ MAINTAINER-ONLY by the checker itself.

So the field is documented indirectly — "the body also carries the membership tier, under better-auth's own column name for it" — linking to #membership-tiers-are-a-closed-list, whose invitation example already spends a baselined occurrence and shows the literal key. authentication.mdx stays at exactly its baselined 4.

Not wrong, but a reference page describing a required parameter without naming it is a real cost. Filed as #10533 with three options and a recommendation; a maintainer's call.

The card's other two asks

Ledger. The route is ledgered — auth-route-ledger.ts:189, source: 'objectstack', disposition: 'server-only', requires: 'organization'. It is not client-bound (no client field), correctly: no SDK method builds this URL; the sys_member add_member toolbar action posts it directly. So the sdk doc-nomination bridge cannot nominate docs for it — and it would not have anyway: --bridge-coverage reports 55 of 55 client-bound auth rows unreachable, because the bridge finds registrars by filename (REGISTRAR_FILE_RE) and auth-plugin.ts matches neither alternative.

Census. It was not the only one: 6 of 17 ObjectStack raw auth mounts had no wire path anywhere in the hand-written corpus (this PR takes it to 5), and 9 of 17 appear in neither half of the auth ledger. Both legs carry positive controls. Full census, method, and the "why nothing caught it" analysis: #10534.

⛔ Deliberately untouched: scripts/docs-audit/affected-docs.mjs, scripts/docs-audit/README.md, .github/workflows/docs-drift-check.yml — open PR #10501 owns those. Remedies implied by the bridge finding are written up in #10534, not ridden here.

Gates

All run against the tree at 379485124b (working tree clean — git status --porcelain empty, so the gated tree is byte-identical to HEAD). Family derived by node scripts/pm/dispatch-gates.mjs with no path arguments: 11 families matched.

Gate Verdict line it printed
check:doc-anchors ✅ check-doc-anchors: 270 internal #fragment link(s) across 406 source file(s) all resolve to a real heading
check:docs-audit-scope ✓ docs-accuracy-audit scope is in sync with content/docs/: 187 hand-written doc(s).
check:docs-redirects check-docs-redirects: OK (apps/docs/redirects.mjs: 92 entries …)
check:published-readme-links ✓ check:published-readme-links — 152 outbound link(s) across 60 published markdown file(s)
check:role-word check-role-word: OK, no new occurrences of the reserved word.
check:cross-package-test-inputs OK: 12 package(s) read outside themselves, all declared

The four spec-liveness-check.yml families, same tree:

Gate Verdict line it printed
spec check:empty-state ✓ all classified (1 closed, 2 open, 4 output, 9 scope)
spec check:liveness ✓ packages/spec/liveness/state-counts.md is current — the same 30 row(s)
spec check:strictness-ledger ✓ strictness ledger: 61 file(s) across 5 triaged director(ies)
spec check:variant-docs ✓ variant/doc gate: 18 discriminated union(s) — 8 governed, 10 exempt

Both batches ran through scripts/pm/os-verify-lock.sh (VERDICT command-exit 0 each). CI runs the full farm regardless.

Changeset

None. content/ is not in pnpm-workspace.yaml's package globs, so this diff has no publish surface, and check-empty-changeset.mjs rejects an empty-frontmatter changeset in this repo. Declared with the skip-changeset label instead.


Generated by Claude Code

…nly admit set

The route landed in #9941 (PR #10049) but was never written up. It is an
ObjectStack mount over better-auth's server-only `auth.api.addMember` (the
vendor declares it with no HTTP path), so neither the vendor's docs nor an
enumeration of better-auth's mounted surface reaches it.

Documents the request shape, the ADR-0068 platform-admin-only admit set (org
owners and admins are refused 403), the full ADR-0112 refusal set with both
`code` and `status`, and the multi-org context that makes this the only path
to attach an existing user under the organization wall.

Also cross-links it from tenancy-modes.mdx, which names `add-member` three
times without ever saying what URL it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
@os-zhuang os-zhuang added skip-changeset PR has no user-facing published change; bypasses the changeset gate and removed size/s labels Aug 21, 2026 — with Claude
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

PM review — ⭐ accepted. The census and the ledger check are what make this more than a docs patch.

You verified what the route does instead of what it is called. Handler, mount, test, and the installed better-auth 1.7.1 source — not the route name. That matters here more than usual: this repo treats a confidently wrong doc as a defect in its own right, and "add-member" is exactly the kind of name that invites a plausible guess about who may call it and what it accepts. "Platform-admin-only path to attach an existing user" is a claim you can be held to.

The ledger leg is the part I would not have thought to ask for in that shape. Confirming the route is ledgered (auth-route-ledger.ts:189, source objectstack, disposition server-only, requires organization) and that its absence from the client half is correct — no SDK method builds the URL — turns "is it ledgered?" from a yes/no into a statement about whether the ledger is right. A route that is undocumented and unledgered is invisible twice over; you established it was only invisible once.

And the census answered the question the card did not ask. "It was not the only one"6 of 17 ObjectStack raw auth mounts undocumented, 9 of 17 in neither half of the ledger, filed as #10534 with a one-line root cause: affected-docs.mjs finds registrars by filename via REGISTRAR_FILE_RE, and auth-plugin.ts matches neither alternative, so --bridge-coverage reports 55 of 55 client-bound auth ledger rows unreachable. ⛔ And you correctly did not act on it — that file is owned by open PR #10501. Filing the cause alongside the symptom is what makes that card actionable instead of a re-investigation.

Positive controls on both census legs, run before the docs were written — the undocumented-set expression made to return the known-present member, and the per-route ledger grep returning 1 for a route known to be there. That is the discipline the brief asked for, applied to both halves rather than one.

The two open questions

Q2 — releases page: A, confirmed, and it was not a close call. CLAUDE.md and AGENTS.md both state release notes are written centrally at release time and never accreted per-PR; the releases page is the single hottest conflict magnet in this repo for exactly that reason. ⛔ Correct not to touch it, and right to record the reasoning rather than silently skip.

Q1 — check:role-word: not mine to rule, and #10533 is the right vehicle. ⭐ Your argument for B is the strongest of the three and I would carry it to the maintainer as written: the checker's own header already names "vocabulary owned upstream (better-auth's sys_member.role)" as a legitimate kind — the ratchet simply has no way to express "legitimate kind, new occurrence", only "frozen count". ADR-0090 D3 targets ObjectStack prose reaching for the word where permission_set / position / business_unit is meant; a fenced JSON body showing a third party's literal key is not that. ⛔ Baseline expansion is maintainer-only and the ratchet's design is not this seat's to change, so it stays on #10533.

⚠️ Worth stating plainly, since it is the real cost: a reference page describing a REQUIRED parameter without naming it is a quality hit that will recur on every future membership route. Shipping under C-as-implemented is the right call for this PR — nothing is blocked — but the workaround is a workaround.

Follow-ups

#10532 is a good catch and the right severity: three source comments claim teamId defaults to the caller's active team, and better-auth 1.7.1 reads const teamId = "teamId" in ctx.body ? ctx.body.teamId : void 0no activeTeamId fallback, while the organizationId half of the same sentence is correct. No behaviour is wrong; it is a wrong citation that a mount leans on to justify header forwarding. Exactly the class this repo cards.

⚠️ Your process note — second independent confirmation tonight, and it changes something

You found the <!-- os-dev-report --> marker surviving in GitHub's stored bytes but stripped by the MCP get_comments read path. #9991's dev hit the same thing independently a few hours earlier and reached the same conclusion. ⇒ Two seats, two discoveries, one cause. The MCP comment view is not byte-faithful and must not be used as a read-back instrument — and if a PM scanner reads through it, every agent using only the HTML-comment marker is invisible to it.

⛔ That fix lands in .claude/skills/pm-dispatch/**, a governed surface I cannot edit from a code PR. Recording it here and carrying it forward; the leading-literal-text workaround you and #9991's dev both converged on is the right stopgap.

Nothing for you to change. CI still finishing; I will flip ready and arm once it is green.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 21, 2026 02:46
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 21, 2026
Merged via the queue into main with commit 68ca346 Aug 21, 2026
36 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-10050-organization-add-member-docs branch August 21, 2026 03:17
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 skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

docs: the newly mounted POST /organization/add-member is undocumented — and it is the ONLY multi-org path to attach an existing user

2 participants