Skip to content

feat(plugin-auth): mount admin-gated POST /organization/add-member wrapping better-auth's server-only addMember - #10049

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-9941-add-member-http-route
Aug 19, 2026
Merged

feat(plugin-auth): mount admin-gated POST /organization/add-member wrapping better-auth's server-only addMember#10049
os-warren merged 3 commits into
mainfrom
claude/issue-9941-add-member-http-route

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #9941

Premise re-verified — on the INSTALLED vendor, which is 1.7.1, not the card's 1.7.0-rc.2

The installed better-auth in this tree is 1.7.1 (packages/plugins/plugin-auth/node_modules/better-auth/package.json). The card cited 1.7.0-rc.2; the vendor bump did not change the fact:

  • dist/plugins/organization/routes/crud-members.mjs:20-25addMember returns createAuthEndpoint({ method: "POST", ... }) with no path argument → server-only, never mounted by the catch-all.
  • Same file :139removeMember keeps "/organization/remove-member"; :236updateMemberRole keeps "/organization/update-member-role".
  • dist/plugins/organization/organization.mjs:326 — the vendor's own doc: "Server-only: callable as auth.api.addMember from trusted server".

The three contradicting sites named by the card were all intact on origin/main@09b880b0c: sys-member.object.ts:54 (the add_member action target), adopt-membership.ts:72 (the stale "POST /organization/add-member pre-checks and refuses first" claim), and the ledger listing only add-team-member (:182, :333) with the 'server-only' arm at :60 unused.

What ships (triage's preferred shape — restores a declared surface, widens nothing)

  • organization-add-member.ts (new): runOrganizationAddMember(deps, request) wraps auth.api.addMember server-side. Ordering per ADR-0112, anonymous-first: identity (gate, at the mount) → capability (501 NOT_IMPLEMENTED when the organization plugin is off) → body validation (400 INVALID_REQUEST) → the vendor's own verdicts forwarded verbatim (USER_NOT_FOUND, NO_ACTIVE_ORGANIZATION, USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION, …). Request headers are forwarded so an omitted organizationId defaults to the caller's active org — the behaviour the action metadata documents. No sys_member row is written directly (ADR-0010 lock respected): the row lands through the vendor's own createMember via the objectql adapter.
  • auth-plugin.ts: mounts POST ${basePath}/organization/add-member ahead of the catch-all, gated by the shared gateAdminjudgePlatformAdmin (platform-admin-gate.ts; no new predicate copy — same hoist PR fix(plugin-auth): gate the four /admin/sso/* bridges with the shared ADR-0068 platform-admin judge (#9653) #10013 landed for the /admin/sso/* bridges). Admitted writes run under runAttributedToUser(actor.id, …) so the membership is credited to the admin. Admit set: platform admin only — org owners/admins are refused 403 (finding: the direct /sso/register endpoint's ADR-0024 before-hook admits org owners/admins — wider than the platform-admin posture #9653 landed on the /admin/sso/* bridges #10009 remains open about a sibling's wider admit set; this route does not repeat that shape).
  • auth-route-ledger.ts: new row POST /api/v1/auth/organization/add-member, source: 'objectstack', disposition: 'server-only' (the previously-unused arm — it fits: the route is deliberately not SDK surface; no client method builds this URL, the sys_member action posts it directly), requires: 'organization', with a rationale note. BETTER_AUTH_MOUNTED_SURFACE is deliberately untouched: the vendor endpoint has no path, so the live enumeration (which reads .path) never sees it, and the exact-equality pin stays honest.
  • auth-route-ledger.conformance.test.ts: the pinned source: 'objectstack' set grows to three rows; the "should NOT come from better-auth" live-check holds for the new row by construction.
  • adopt-membership.ts: the stale contract sentence now says the vendor endpoint is server-only, reachable over HTTP only through this admin-gated mount, re-measured on 1.7.1 — the pre-check claim itself (findMemberByEmail → 400) is still true and is now pinned live by a test.
  • admin-user-endpoints.ts: mapAuthApiError exported (reused, not copied).
  • sys-member.object.ts is untouched: its target was correct all along — the route underneath it now exists. No metadata string changed ⇒ no dist/i18n/example-showcase consumer surface moved (the [finding] sys_notification_subscription rows have no runtime consumer — the audience: 'subscribers' expansion the object header describes is not implemented #9807 sweep clause is satisfied by construction; verified no diff outside plugin-auth + changeset + the double-contract ledger).
  • Changeset: .changeset/organization-add-member-mounted.md (patch, states route + admit set in release-notes terms).

Pins (organization-add-member.test.ts, 11 tests — real multi-org fixture)

Real AuthManager + in-memory engine (the remove-member-guard harness: dispatch-asserted double, sys_member UNIQUE enforced), two organizations, the plugin's real route registration on a real Hono app, REAL better-auth sessions. The platform admin is real ADR-0068 signal (admin_full_access + org-less sys_user_permission_set link) — no role='admin' scalar patched anywhere.

  • premise kept measured: fresh user on multi-org has zero memberships and an empty organization/list (the "create a workspace" bootstrap condition);
  • anon → 401 UNAUTHENTICATED even with an invalid body (identity before body validation), nothing written;
  • signed-in member → 403 PERMISSION_DENIED, nothing written;
  • org owner403 PERMISSION_DENIED (admit set is platform-admin only);
  • platform admin → 200, the vendor addMember runs, the sys_member row lands (org, user, role asserted), and the user's own session now sees the org — the card's blocker, pinned end to end;
  • repeat → 400 USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION (vendor pre-check, kept live for adopt-membership's doc);
  • unknown user → 400 USER_NOT_FOUND; omitted org + no active org → 400 NO_ACTIVE_ORGANIZATION; missing userId/role → 400 INVALID_REQUEST;
  • module-level: organization plugin off → 501 NOT_IMPLEMENTED before body validation; body/headers forwarding pinned.

All rejection pins assert code AND status.

Ablation (gate neutered in the mount, predictions stated first)

Predicted: anon 401→400 INVALID_REQUEST; member 403→200 with the unauthorized sys_member write landing; org-owner 403→vendor 400 already-a-member (poisoned by the member pin's write); blocker test poisoned (2 rows). Observed: exactly that — 5 failed / 6 passed, each red for the predicted reason (expected 401, received 400; expected 403, received 200; expected 403, received 400; length 1 but got 2 ×2). No rebuild was required for the mutation to be observable: the suite imports ./auth-plugin by same-package relative path, which vitest resolves to source; the ablation marker was verified absent from dist/ on both legs. Restored byte-identically: git hash-object = 56c783b059d81e058c540079db786c204bc8351f before and after; restored leg re-run green (11/11).

Gates — union run at final commit 918435096

node scripts/pm/dispatch-gates.mjs re-derived from the actual diff (no hand-fed paths), re-run after the last commit:

  • pnpm --filter @objectstack/plugin-auth testTest Files 58 passed (58) · Tests 1312 passed (1312) (includes the ledger conformance suite)
  • pnpm --filter @objectstack/plugin-auth typechecktsc --noEmit, exit 0
  • check:route-envelope — "✓ IHttpServer express-style modules — 1 module(s) audited … 1 conformant, 0 ratcheted"
  • check:engine-double-contract — "OK — 323 pinned, 133 in the DEBT ledger, 2 exempt" (new double registered via the gate's own --write; ledger artifact committed)
  • check:type-check-debt — "OK — 33 ledger entr(ies) re-measured … none above its recorded number" (an untyped vi.fn() had moved plugin-auth's TEST_DEBT 109→110; fixed by typing the mock, back to exactly 109)
  • check:type-check-coverage, check:cross-package-test-inputs ("OK: 12 package(s) read outside themselves, all declared"), check:slot-lookup, check:test-source-alias, check:type-source-resolution, check:query-options-erasure, check:where-matcher, check:nul-bytes ("no raw ASCII control bytes"), check:changeset-gate-self-tests, check:objectui-changeset, check-adr-0087-registration ("no declared-breaking changeset"), check-changeset-no-major, check-empty-changeset, docs-audit/check-affected-docs — all exit 0, verdict lines captured from each gate's own output (exit codes captured before any pipe).

Downstream: with this route mounted, the field report steedos-labs/os-project-titanwind-ehr#1615's blocker is cleared at the platform layer — a platform admin can attach an existing (including phone-number-only) user to an org from the sys_member list toolbar, and the attached user no longer lands on the "create a workspace" bootstrap.

Generated by Claude Code


Generated by Claude Code

claude added 3 commits August 19, 2026 18:18
…apping server-only auth.api.addMember

better-auth (1.7.1 installed) declares addMember with no HTTP path, so the
catch-all never mounts it — yet the sys_member add_member toolbar action
targets it, and on multi-org it is the only UI path to attach an existing
user to an organization. Mount it behind the shared ADR-0068 platform-admin
gate, ledger it as an objectstack server-only row, and fix the stale
adopt-membership doc claim.

Fixes #9941

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…r; type the addMember mock

The double-contract gate's own remedy (--write) records the new pinned
coverage; the typed mock keeps plugin-auth's TEST_DEBT ratchet at its
recorded 109 (an untyped vi.fn() made mock.calls[0][0] a TS2493).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Aug 19, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 20 documentable anchor(s).

43 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 2ee7c453b5e050f8c34bf8e3d9ffe8ad96f850e5.

6 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/plugins/plugin-auth/src/adopt-membership.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 11 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 2ee7c453b5e050f8c34bf8e3d9ffe8ad96f850e5packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 2ee7c453b5e050f8c34bf8e3d9ffe8ad96f850e5

⚠️ 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 2ee7c453b5e050f8c34bf8e3d9ffe8ad96f850e5 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-warren
os-warren marked this pull request as ready for review August 19, 2026 19:19
@os-warren
os-warren added this pull request to the merge queue Aug 19, 2026
Merged via the queue into main with commit 2a6ebaf Aug 19, 2026
26 checks passed
@os-warren
os-warren deleted the claude/issue-9941-add-member-http-route branch August 19, 2026 19:45
os-zhuang pushed a commit that referenced this pull request Aug 21, 2026
…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
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

2 participants