Skip to content

Commit 8f04d9a

Browse files
os-warrenclaude
andauthored
fix(auth): teamId on organization/add-member has no active-team fallback (#10532) (#10715)
Two source comments asserted that better-auth's `organization/add-member` defaults BOTH `organizationId` and `teamId` to the caller's active org/team. Measured on the installed better-auth 1.7.1 (`dist/plugins/organization/routes/crud-members.mjs`, inside `addMember`), only the organization half is true: const orgId = ctx.body.organizationId || session?.session.activeOrganizationId; const teamId = "teamId" in ctx.body ? ctx.body.teamId : void 0; `activeOrganizationId` is read 8 times in that module; `activeTeamId`, 0 times (same grep, so the zero is a measurement and not a broken search). Corrected at the origin (`sys_member`'s `add_member` action metadata) and at the citation that named it as authority (`organization-add-member.ts`, which cited it as the justification for forwarding request headers — forwarding buys the organization default only). No runtime behaviour changes and nothing was ever misled at runtime: the `add_member` action's `params` list carries no `teamId`, so the claim was never exercised. It was a wrong citation, not a live defect. The asymmetry that #10050's docs now publish is held by a new pin, `organization-add-member-team-fallback.test.ts`, which reads the fact out of the INSTALLED vendor artifact rather than out of our own comments, with a positive control on `activeOrganizationId` for every zero-hit leg. Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent b6bb2ee commit 8f04d9a

4 files changed

Lines changed: 244 additions & 8 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
"@objectstack/plugin-auth": patch
4+
---
5+
6+
Correct a false vendor claim in the `organization/add-member` source comments:
7+
`teamId` has **no** active-team fallback (#10532). Two comments — the
8+
`sys_member` `add_member` action metadata (the origin) and the
9+
`organization-add-member.ts` module header that cited it as authority — stated
10+
that "organizationId/teamId default to the caller's active org/team when
11+
omitted". Measured on the installed better-auth 1.7.1
12+
(`dist/plugins/organization/routes/crud-members.mjs`, inside `addMember`), only
13+
the organization half is true:
14+
15+
```js
16+
const orgId = ctx.body.organizationId || session?.session.activeOrganizationId;
17+
const teamId = "teamId" in ctx.body ? ctx.body.teamId : void 0;
18+
```
19+
20+
`activeOrganizationId` is read 8 times in that module; `activeTeamId`, never. An
21+
omitted `teamId` therefore stays `undefined` and the member joins no team — every
22+
`if (teamId)` branch (team lookup, `TEAM_NOT_FOUND`, per-team limit) is skipped.
23+
24+
No runtime behaviour changes, and no deployment was ever misled: the `add_member`
25+
action's `params` list carries no `teamId`, so the toolbar never sent one and the
26+
claim was never exercised. What the comment did mislead was the next reader of
27+
the mount, which cited it as the justification for forwarding request headers —
28+
forwarding buys the organization default only. Forwarding `teamId` itself remains
29+
correct: pass it and it works.
30+
31+
The asymmetry the docs now publish is held by a new pin,
32+
`organization-add-member-team-fallback.test.ts`, which reads the fact out of the
33+
installed vendor artifact (not out of our own comments) so that a future
34+
better-auth bump *adding* an active-team fallback reddens instead of silently
35+
putting the docs out of date.

‎packages/platform-objects/src/identity/sys-member.object.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,18 @@ export const SysMember = ObjectSchema.create({
4343
// Admin-only: directly attach an existing user to the active org,
4444
// bypassing the invite-accept flow. Better-auth:
4545
// `organization/add-member { userId, role, organizationId?, teamId? }`.
46-
// organizationId/teamId default to the caller's active org/team when
47-
// omitted, so we leave them as optional params.
46+
// The two optional fields are NOT symmetric. `organizationId` defaults
47+
// to the caller's active organization when omitted; `teamId` has no such
48+
// fallback — omit it and the member simply joins no team. Measured on the
49+
// installed better-auth 1.7.1
50+
// (`dist/plugins/organization/routes/crud-members.mjs`, `addMember`):
51+
// `ctx.body.organizationId || session?.session.activeOrganizationId`
52+
// against `"teamId" in ctx.body ? ctx.body.teamId : void 0`, and no
53+
// `activeTeamId` read anywhere in that file. So `organizationId` is
54+
// carried as an optional param below and `teamId` is not a param at all
55+
// — this action never sends it, so it never exercises the team half.
56+
// Pinned against a vendor bump that ADDS the fallback by
57+
// plugin-auth's `organization-add-member-team-fallback.test.ts`.
4858
name: 'add_member',
4959
label: 'Add Member',
5060
icon: 'user-plus',
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#10532] `organization/add-member` — `organizationId` falls back to the
5+
* caller's active organization; `teamId` does NOT.
6+
*
7+
* ## What this pin watches, and why it reads the VENDOR
8+
*
9+
* Three of our source comments used to assert the symmetric version of that
10+
* sentence ("organizationId/teamId default to the caller's active org/team when
11+
* omitted"). better-auth 1.7.1 implements only the org half. The comments are
12+
* corrected, and the docs written for #10050 now state the asymmetry to users
13+
* — so the standing risk is no longer a wrong comment but a **vendor bump that
14+
* ADDS an active-team fallback**, which would make our (now correct) docs wrong
15+
* with nothing watching.
16+
*
17+
* Therefore this pin asserts a fact about the INSTALLED vendor artifact, read
18+
* out of `node_modules` at test time. It deliberately does NOT assert anything
19+
* about our own comment text or our own constants: an assertion whose both
20+
* sides derive from files in this repo proves only that we wrote what we wrote,
21+
* and could not fail for the reason it exists.
22+
*
23+
* ## The zero-hit discipline
24+
*
25+
* "There is no active-team fallback" is a NEGATIVE finding, and a negative
26+
* finding from a detector nobody proved is worthless — a typo'd matcher reports
27+
* the same clean absence as a real one. So every negative leg below is paired
28+
* with a POSITIVE control that runs the SAME matcher over a neighbouring fact
29+
* known to be present:
30+
*
31+
* - `SESSION_ACTIVE_FALLBACK` must MATCH the `orgId` binding (which really
32+
* does fall back) before its non-match on the `teamId` binding counts;
33+
* - `activeOrganizationId` must occur in the file before the zero count for
34+
* `activeTeam*` counts.
35+
*
36+
* Measured 2026-08-21 on the installed better-auth 1.7.1,
37+
* `dist/plugins/organization/routes/crud-members.mjs`, inside `addMember`:
38+
*
39+
* const orgId = ctx.body.organizationId || session?.session.activeOrganizationId;
40+
* const teamId = "teamId" in ctx.body ? ctx.body.teamId : void 0;
41+
*
42+
* `activeOrganizationId` occurs 8 times in that file; `activeTeamId`, 0.
43+
*/
44+
45+
import { describe, it, expect } from 'vitest';
46+
import { readFileSync, existsSync } from 'node:fs';
47+
import { createRequire } from 'node:module';
48+
import { dirname, join } from 'node:path';
49+
50+
/**
51+
* Locate this package by walking up from the CWD — the idiom
52+
* `member-role-canonical.test.ts` / `rate-limit-storage-isolation.test.ts`
53+
* established here and state the reason for: plugin-auth is CJS-typed (no
54+
* `"type": "module"`, it publishes `dist/index.js` as CommonJS), so under
55+
* `module: NodeNext` `import.meta` is a TS1470 in this package however well it
56+
* runs under vitest.
57+
*/
58+
function findUp(predicate: (dir: string) => boolean, what: string): string {
59+
let dir = process.cwd();
60+
for (;;) {
61+
if (predicate(dir)) return dir;
62+
const parent = dirname(dir);
63+
if (parent === dir) throw new Error(`could not locate ${what}`);
64+
dir = parent;
65+
}
66+
}
67+
68+
const PKG = findUp((dir) => {
69+
const manifest = join(dir, 'package.json');
70+
if (!existsSync(manifest)) return false;
71+
const { name } = JSON.parse(readFileSync(manifest, 'utf8')) as { name?: string };
72+
return name === '@objectstack/plugin-auth';
73+
}, 'the @objectstack/plugin-auth package root');
74+
75+
// Resolved from THIS package, so the file read is the better-auth this package
76+
// is pinned to — not whatever a hoist happens to put at the repo root. The read
77+
// goes through `node_modules` at test time; there is no checked-in copy of the
78+
// vendor text for it to fall back to, which is what the ablation in the PR body
79+
// demonstrates (mutate the file under `node_modules` and this suite reddens).
80+
const require_ = createRequire(join(PKG, 'probe.js'));
81+
/** `…/better-auth/dist/index.mjs` → `…/better-auth/dist`. */
82+
const VENDOR_DIST = dirname(require_.resolve('better-auth'));
83+
const CRUD_MEMBERS = join(VENDOR_DIST, 'plugins', 'organization', 'routes', 'crud-members.mjs');
84+
const VENDOR_SOURCE = readFileSync(CRUD_MEMBERS, 'utf8');
85+
86+
/**
87+
* The whole top-level `const <name> = …` declaration, up to the next top-level
88+
* `const` (declarations inside the body are indented, so a line-start `const`
89+
* marks the next sibling). Throws when the declaration is gone — the drift
90+
* tripwire: a vendor upgrade that restructures the module fails loudly instead
91+
* of leaving a pin that matches nothing and passes.
92+
*/
93+
function topLevelDecl(source: string, name: string): string {
94+
const start = source.indexOf(`\nconst ${name} = `);
95+
if (start < 0) {
96+
throw new Error(
97+
`[#10532] could not find the top-level \`${name}\` declaration in ${CRUD_MEMBERS}. ` +
98+
`better-auth restructured this module — re-read \`addMember\` and re-decide whether ` +
99+
`\`teamId\` still has no active-team fallback (our comments and the ` +
100+
`content/docs/permissions/authentication.mdx "Attaching an existing user to an ` +
101+
`organization" section both state that it does not).`,
102+
);
103+
}
104+
const rest = source.slice(start + 1);
105+
const next = rest.indexOf('\nconst ');
106+
return next < 0 ? rest : rest.slice(0, next);
107+
}
108+
109+
/**
110+
* The initialiser of a single-line `const <name> = …;` binding inside a slice,
111+
* returned as vendor BYTES. Throws when absent, for the same reason as above.
112+
*/
113+
function bindingInitializer(slice: string, name: string): string {
114+
const match = new RegExp(`\\n\\s*const ${name} = ([^;\\n]+);`).exec(slice);
115+
if (!match) {
116+
throw new Error(
117+
`[#10532] could not find the \`const ${name} = …;\` binding inside \`addMember\` in ` +
118+
`${CRUD_MEMBERS}. better-auth rewrote the handler — re-read it and re-decide whether ` +
119+
`\`teamId\` still has no active-team fallback.`,
120+
);
121+
}
122+
return match[1]!;
123+
}
124+
125+
/**
126+
* "This binding falls back to something on the caller's session." One matcher,
127+
* used on both bindings, so the negative leg is only ever read after the same
128+
* matcher has been shown to fire on the positive one.
129+
*/
130+
const SESSION_ACTIVE_FALLBACK = /session[\s\S]*\bactive[A-Z]\w*/;
131+
132+
const ADD_MEMBER = topLevelDecl(VENDOR_SOURCE, 'addMember');
133+
134+
const occurrences = (needle: RegExp): number =>
135+
VENDOR_SOURCE.match(new RegExp(needle.source, 'g'))?.length ?? 0;
136+
137+
describe('[#10532] better-auth `addMember` — the org/team fallback asymmetry', () => {
138+
it('CONTROL: the `orgId` binding really does fall back to the session active org', () => {
139+
// Positive control for BOTH instruments used below: it proves the vendor
140+
// file was located and sliced, and that `SESSION_ACTIVE_FALLBACK` fires on
141+
// a binding that carries a session fallback. Without this leg passing, the
142+
// non-match in the next test is not evidence of anything.
143+
const orgId = bindingInitializer(ADD_MEMBER, 'orgId');
144+
expect(orgId).toMatch(SESSION_ACTIVE_FALLBACK);
145+
expect(orgId).toContain('activeOrganizationId');
146+
});
147+
148+
it('the `teamId` binding has NO fallback — it reads the request body and nothing else', () => {
149+
const teamId = bindingInitializer(ADD_MEMBER, 'teamId');
150+
// Same matcher that just fired on `orgId`.
151+
expect(teamId).not.toMatch(SESSION_ACTIVE_FALLBACK);
152+
// …and positively: the only source of the value is the request body.
153+
expect(teamId).toContain('ctx.body');
154+
expect(teamId).not.toContain('session');
155+
});
156+
157+
it('no active-team read anywhere in the module (with the neighbouring positive control)', () => {
158+
// The control first: if this ever hits 0 the grep itself is broken and the
159+
// zero below means nothing.
160+
expect(occurrences(/activeOrganizationId/)).toBeGreaterThan(0);
161+
expect(occurrences(/active[Tt]eam\w*/)).toBe(0);
162+
});
163+
164+
it('the `if (teamId)` branches stay skipped when teamId is absent — no re-resolution later', () => {
165+
// The remaining way the vendor could acquire an active team is to
166+
// re-resolve it after the binding. Same control shape: the guard we expect
167+
// to be there is asserted present, then the re-resolution is asserted
168+
// absent across the whole handler.
169+
expect(ADD_MEMBER).toMatch(/if \(teamId\)/);
170+
expect(ADD_MEMBER).not.toMatch(/teamId\s*(?:\|\||\?\?)=?/);
171+
});
172+
});

‎packages/plugins/plugin-auth/src/organization-add-member.ts‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,32 @@
4242
* capability error (501 when the organization plugin is off) → body
4343
* validation (400) → the vendor's own verdicts, forwarded verbatim.
4444
*
45-
* ── organizationId default ──────────────────────────────────────────────────
45+
* ── organizationId defaults; teamId does NOT ────────────────────────────────
4646
*
47-
* The request headers are forwarded into `auth.api.addMember`, so an omitted
48-
* `organizationId` defaults to the CALLER's active organization — the
49-
* behaviour the `sys_member` action metadata documents ("organizationId/teamId
50-
* default to the caller's active org/team when omitted"). An admin with no
51-
* active organization gets the vendor's 400 `NO_ACTIVE_ORGANIZATION`.
47+
* The two optional fields are NOT symmetric. `organizationId` defaults to the
48+
* caller's active organization when omitted; `teamId` has no such fallback —
49+
* omit it and the member simply joins no team. Measured on the installed
50+
* better-auth 1.7.1 (`dist/plugins/organization/routes/crud-members.mjs`,
51+
* inside `addMember`'s handler):
52+
*
53+
* const orgId = ctx.body.organizationId || session?.session.activeOrganizationId;
54+
* const teamId = "teamId" in ctx.body ? ctx.body.teamId : void 0;
55+
*
56+
* and no `activeTeamId` read anywhere in that file (`activeOrganizationId`
57+
* hits 8 times in the same grep, which is what proves the search works).
58+
*
59+
* So what header forwarding buys is the ORG default only: an admin with no
60+
* active organization gets the vendor's 400 `NO_ACTIVE_ORGANIZATION`, while an
61+
* omitted `teamId` stays `undefined` and every `if (teamId)` branch in the
62+
* vendor handler (team lookup, `TEAM_NOT_FOUND`, per-team limit) is skipped.
63+
* Forwarding `teamId` itself is still correct — pass it and it works.
64+
*
65+
* The `sys_member` `add_member` action metadata used to assert the symmetric
66+
* version of this sentence; it was a wrong citation rather than a live defect,
67+
* because that action's `params` list carries no `teamId` and so never sent
68+
* one. Corrected at the origin in the same change, and pinned against a vendor
69+
* bump that ADDS an active-team fallback by
70+
* `organization-add-member-team-fallback.test.ts`.
5271
*/
5372

5473
import { mapAuthApiError, type EndpointResult } from './admin-user-endpoints.js';

0 commit comments

Comments
 (0)