Uh oh!
There was an error while loading. Please reload this page.
docs: document POST /organization/add-member — the platform-admin-only path to attach an existing user - #10535
Conversation
…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
commented
Aug 21, 2026
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 ( ⭐ 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: ✅ 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 questionsQ2 — releases page: A, confirmed, and it was not a close call. Q1 — Follow-ups#10532 is a good catch and the right severity: three source comments claim
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes: #10050
Documents
POST /api/v1/auth/organization/add-member, mounted by #9941 / PR #10049 and never written up. Docs-only — two files undercontent/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 atauth-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.addMemberis built withcreateAuthEndpoint({ 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 enumeratingBETTER_AUTH_MOUNTED_SURFACEdoes not reach it.auth-plugin.tsmounts the URL ahead of the catch-all.401 UNAUTHENTICATED, plain member →403, and an organization owner →403 PERMISSION_DENIED. The org-owner refusal is the surprising one and is now stated as deliberate.501when 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.codeandstatusper ADR-0112. The vendor's four are forwarded verbatim bymapAuthApiError, which preserves the vendor's status — soUSER_NOT_FOUND/USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION/NO_ACTIVE_ORGANIZATIONare400(APIError.from("BAD_REQUEST", …)) andORGANIZATION_MEMBERSHIP_LIMIT_REACHEDis403(APIError.from("FORBIDDEN", …), default limit 100). The membership-limit row is read from vendor source, not from a test — no test exercises it.organizationIdomitted falls back to the caller's active organization:const orgId = ctx.body.organizationId || session?.session.activeOrganizationId. Pinned by theNO_ACTIVE_ORGANIZATIONtest.teamIddoes 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 claimteamIdonorganization/add-memberdefaults 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 organizationunder the existing### Organizations (Multi-Tenant)section (beside the invitation flow it is the alternative to), plus a#### Organization Membershipentry in the## API Referenceendpoint 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 namedadd-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.mdxis the only non-release page that documents/api/v1/auth/admin/*routes, andtenancy-modes.mdxis the only one that discusses attaching users under the org wall.The route's body carries better-auth's
rolefield, and it has no snake_case alias — the wire name isroleand nothing else.check:role-wordis a per-file exact ratchet overcontent/docswith 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.mdxstays 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 (noclientfield), correctly: no SDK method builds this URL; thesys_memberadd_membertoolbar action posts it directly. So thesdkdoc-nomination bridge cannot nominate docs for it — and it would not have anyway:--bridge-coveragereports 55 of 55 client-bound auth rows unreachable, because the bridge finds registrars by filename (REGISTRAR_FILE_RE) andauth-plugin.tsmatches 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 --porcelainempty, so the gated tree is byte-identical to HEAD). Family derived bynode scripts/pm/dispatch-gates.mjswith no path arguments: 11 families matched.check:doc-anchors✅ check-doc-anchors: 270 internal #fragment link(s) across 406 source file(s) all resolve to a real headingcheck: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 declaredThe four
spec-liveness-check.ymlfamilies, same tree: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 exemptBoth batches ran through
scripts/pm/os-verify-lock.sh(VERDICT command-exit 0each). CI runs the full farm regardless.Changeset
None.
content/is not inpnpm-workspace.yaml's package globs, so this diff has no publish surface, andcheck-empty-changeset.mjsrejects an empty-frontmatter changeset in this repo. Declared with theskip-changesetlabel instead.Generated by Claude Code