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 codeandstatus 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-memberthree 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_memberadd_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.

GateVerdict 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-redirectscheck-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-wordcheck-role-word: OK, no new occurrences of the reserved word.
check:cross-package-test-inputsOK: 12 package(s) read outside themselves, all declared

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

GateVerdict 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-zhuangos-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-actionsgithub-actionsBot added the documentation Improvements or additions to documentation label Aug 21, 2026
@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

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 queueAug 21, 2026
Merged via the queue into main with commit 68ca346Aug 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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationskip-changesetPR 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

@os-zhuang@claude