Skip to content

Commit fc4ac58

Browse files
feat(platform-objects): Add Member opened from a team binds that team (#25670)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuBKyGxNYJyVxQVGvyziBNBD)_ Fixes #25337 Clause-②: no Metadata-protocol: none ## What changes In Setup → Teams, Add Member lived only on the team-membership list's toolbar, where no team is in hand, so its dialog asked the administrator to pick the team a second time. `sys_team` now declares its own `add_team_member` action, on the team row (`list_item`) and the team record header (`record_header`): - It calls the same `POST /api/v1/auth/organization/add-team-member` endpoint as the membership-list copy, behind the same gates (`requiresFeature: 'organization'` and `requiresMembershipReach: 'add_team_member'`). - The team's id rides as `teamId` from the row the action is opened on (`recordIdParam: 'teamId'`), so the dialog asks only for the user (`userId`, through the membership row's `sys_user` lookup via `objectOverride: 'sys_team_member'`). - The membership-list copy (`sys_team_member.add_team_member`) is unchanged and still asks for both. - Translations for the new action label and success message in all four bundles; `platform-objects` patch changeset. This is the add-member half of the card only. Under ruling C the delete half is not done: no disabled-reason key, no computed last-team fact, and `allowRemovingAllTeams` stays unset. ## Outside the claim's file surface `packages/spec/src/kernel/public-auth-features.ts` gains one row, `'sys_team.actions.add_team_member'`, in `PUBLIC_AUTH_FEATURES.organization.gatedInputs`, immediately after `'sys_team.actions.remove_team'`, with an `@objectstack/spec` patch changeset (`.changeset/25337-spec-book-team-add-member.md`). The existing `'sys_team_member.actions.add_team_member'` row (the membership-list action) is untouched. This sits in the `domain:spec` lane and lands here under ruling `6370058183` (A + C) and its confirmation `6384716955`: `feature-gate-guard.test.ts` checks the booking in both directions, so the row can only land in the same PR as the action. A declaration is posted on spec seat post #6017. The spec face goes through the CONTRACT_REVIEW_TIER review before the queue. Serial note: open PR #25626 and the second PR under #25347 each move a different row of the same file. Neither had landed when this PR opened (`origin/main` at `2c523d010`; the only commit since this branch's merge of `main` touches `.claude/skills/pm-dispatch/references/**` and `scripts/pm/check-half-states.mjs`). Whichever lands later merges `main` and re-runs `feature-gate-guard.test.ts`. ## Tests All readings below are on head `67affbcd1`. - `pnpm --filter @objectstack/platform-objects exec vitest run --maxWorkers=2 src/feature-gate-guard.test.ts src/identity/sys-team-add-member-action.test.ts src/platform-objects.test.ts` → 3 files, 259 tests passed (after `pnpm --filter '@objectstack/platform-objects^...' build`, so the guard reads the rebuilt spec `dist/`). - `pnpm --filter @objectstack/platform-objects test` → 95 files, 1351 tests passed. - `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/kernel/public-auth-features.test.ts src/ui/action.test.ts` → 2 files, 177 tests passed. - `pnpm --filter @objectstack/platform-objects --filter @objectstack/spec run typecheck` → exit 0. - ESLint (`--no-inline-config`) on the eight changed source files → exit 0. ### Ablation: the guard turns red without the row The guard reads `@objectstack/spec/kernel` through `exports` (the built `dist/`), so each leg rebuilt spec and proved the marker's state in `dist/` before the run was read. - Mutate: `scripts/ablation-replace.mjs` deleted the row (anchor 1 → 0, blob `bc905d9641b5` → `5b8ba535bc57`); `pnpm --filter @objectstack/spec build`; `scripts/ablation-dist-preflight.mjs @objectstack/spec 'sys_team.actions.add_team_member' --absent` → marker absent from all 232 built files. Guard run → **1 failed | 102 passed (103)**: ``` FAIL src/feature-gate-guard.test.ts > feature-gate completeness guard (#2874) > reverse: every features.* gate in the objects is booked in the registry > sys_team.actions.add_team_member references registered flag organization and is booked AssertionError: sys_team.actions.add_team_member is booked in organization.gatedInputs: expected false to be true ``` - Restore: blob back to `bc905d9641b5` (equal to HEAD), `git diff HEAD` empty, whole-tree `git status --porcelain` clean; spec rebuilt; preflight (present) → marker in 30 built files, tree clean. Guard run → **104 passed (104)** (the booked row also adds its forward-direction case). ## Gates `node scripts/pm/dispatch-gates.mjs --commands` derived 91 commands for this change set; every one was run on `67affbcd1` and recorded as `cmd :: exit N`. All 91 exit 0. Four first answered exit 3 (PREREQUISITE NOT MET: `check:i18n`, `check:lean-entry-closure` and `check:dual-build-cjs-loads` needed built packages, and the `check-plugin-teardown-shape.mjs --self-test` fixture commit was outside the shallow clone). Each was re-run after the named prerequisite was met (the i18n closure build; fetching the pinned fixture commit) and exited 0. `node scripts/pm/dispatch-gates.mjs --ran` → "91 derived famil(ies) accounted for — 91 run, 0 NOT-MEASURED". Local-only `node scripts/check-dev-prereqs.mjs` → exit 0. CI owns the type-check lanes, Test Core, Build Core and the roster families outside the derived set. ## Acceptance notes - No new runtime path: the action is metadata only, and it reuses an endpoint and gates that are already served. - `packages/plugins/plugin-auth/src/public-feature-registry.test.ts` also imports `PUBLIC_AUTH_FEATURES`, but it reads flag names and semantics only, never `gatedInputs`. It was not run locally (its dependency closure is unbuilt here); CI's Test Core runs it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JrXUZYyTV4i588KLZRxSvJ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 24d350b commit fc4ac58

10 files changed

Lines changed: 137 additions & 0 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): book the team-side Add Member action under the `organization` feature gate
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`PUBLIC_AUTH_FEATURES.organization.gatedInputs` now lists `sys_team.actions.add_team_member`, right after `sys_team.actions.remove_team`. `@objectstack/platform-objects` declares that action on `sys_team` (Add Member opened from a team, which binds the team) with `requiresFeature: 'organization'`, and the booking keeps the two sides agreeing: every action gated on a feature is listed under it, and every listed input carries the gate. The membership-list copy, `sys_team_member.actions.add_team_member`, keeps its own row unchanged.
11+
12+
What to do: nothing. No key, value or predicate changes; the list gains one entry.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
feat: Add Member opened from a team now binds that team, so the dialog asks only for the user
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
In Setup → Teams, Add Member lived only on the team-membership list's toolbar. No team is in hand there, so the dialog asked the administrator to pick the team again. `sys_team` now also declares `add_team_member`, on the team row (`list_item`) and the team record header (`record_header`). It calls the same `POST /api/v1/auth/organization/add-team-member` endpoint behind the same gates (the `organization` feature and the `add_team_member` membership grade). The team's id rides as `teamId` from the row it is opened on, and the dialog asks only for the user. The membership-list copy is unchanged and still asks for both.

‎packages/platform-objects/src/apps/translations/en.objects.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
869869
label: "Edit Team",
870870
successMessage: "Team updated"
871871
},
872+
add_team_member: {
873+
label: "Add Member",
874+
successMessage: "Team member added"
875+
},
872876
remove_team: {
873877
label: "Delete Team",
874878
confirmText: "Delete this team? Members will lose any team-scoped access. This cannot be undone.",

‎packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
869869
label: "Editar equipo",
870870
successMessage: "Equipo actualizado"
871871
},
872+
add_team_member: {
873+
label: "Añadir miembro",
874+
successMessage: "Miembro del equipo añadido"
875+
},
872876
remove_team: {
873877
label: "Eliminar equipo",
874878
confirmText: "¿Eliminar este equipo? Los miembros perderán cualquier acceso con ámbito de equipo. Esta acción no se puede deshacer.",

‎packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
869869
label: "チームを編集",
870870
successMessage: "チームを更新しました"
871871
},
872+
add_team_member: {
873+
label: "メンバーを追加",
874+
successMessage: "チームメンバーを追加しました"
875+
},
872876
remove_team: {
873877
label: "チームを削除",
874878
confirmText: "このチームを削除しますか?メンバーはチームスコープのアクセスを失います。この操作は取り消せません。",

‎packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
869869
label: "编辑团队",
870870
successMessage: "团队已更新"
871871
},
872+
add_team_member: {
873+
label: "添加成员",
874+
successMessage: "团队成员已添加"
875+
},
872876
remove_team: {
873877
label: "删除团队",
874878
confirmText: "要删除该团队吗?成员将失去所有团队范围内的访问权限。此操作无法撤销。",
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Add Member opened from a team carries that team.
4+
//
5+
// `sys_team_member.add_team_member` sits on the membership list's toolbar,
6+
// where no team is in hand, so its dialog asks for the team as well as the
7+
// user. The team-side copy on `sys_team` binds the team from the row it is
8+
// opened on (`recordIdParam: 'teamId'`), so the dialog asks for the user
9+
// alone. Both copies call the same endpoint behind the same gates; this file
10+
// pins that the two agree on everything except where the team comes from.
11+
import { describe, expect, it } from 'vitest';
12+
import { SysTeam } from './sys-team.object.js';
13+
import { SysTeamMember } from './sys-team-member.object.js';
14+
15+
interface ParamLike {
16+
name?: string;
17+
field?: string;
18+
objectOverride?: string;
19+
required?: boolean;
20+
defaultFromRow?: boolean;
21+
}
22+
23+
interface ActionLike {
24+
name?: string;
25+
type?: string;
26+
target?: string;
27+
recordIdParam?: string;
28+
locations?: string[];
29+
visible?: { source?: string };
30+
params?: ParamLike[];
31+
}
32+
33+
function action(object: { actions?: unknown[] }, name: string): ActionLike {
34+
const found = ((object.actions ?? []) as ActionLike[]).find((a) => a.name === name);
35+
expect(found, `${name} is declared`).toBeDefined();
36+
return found as ActionLike;
37+
}
38+
39+
describe('sys_team.add_team_member — Add Member opened from a team', () => {
40+
const fromTeam = action(SysTeam, 'add_team_member');
41+
const fromList = action(SysTeamMember, 'add_team_member');
42+
43+
it('is offered on the team row and the team record header', () => {
44+
expect(fromTeam.locations).toEqual(['list_item', 'record_header']);
45+
});
46+
47+
it('binds the team from the row it is opened on, as teamId', () => {
48+
expect(fromTeam.recordIdParam).toBe('teamId');
49+
// The team is not asked for: no dialog param submits teamId.
50+
expect((fromTeam.params ?? []).map((p) => p.name)).not.toContain('teamId');
51+
});
52+
53+
it('asks only for the user, through the membership row\'s sys_user lookup', () => {
54+
expect(fromTeam.params).toEqual([
55+
{ name: 'userId', field: 'user_id', objectOverride: 'sys_team_member', required: true },
56+
]);
57+
});
58+
59+
it('calls the same endpoint behind the same gate as the membership-list copy', () => {
60+
expect(fromTeam.type).toBe('api');
61+
expect(fromTeam.target).toBe('/api/v1/auth/organization/add-team-member');
62+
expect(fromTeam.target).toBe(fromList.target);
63+
expect(fromTeam.visible?.source).toBeDefined();
64+
expect(fromTeam.visible?.source).toBe(fromList.visible?.source);
65+
});
66+
67+
it('leaves the membership-list copy asking for the team, where none is in hand', () => {
68+
expect(fromList.locations).toEqual(['list_toolbar']);
69+
expect(fromList.recordIdParam).toBeUndefined();
70+
expect((fromList.params ?? []).map((p) => p.name)).toEqual(['teamId', 'userId']);
71+
});
72+
});

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,31 @@ export const SysTeam = ObjectSchema.create({
7878
{ field: 'name', required: true, defaultFromRow: true },
7979
],
8080
},
81+
{
82+
// Better-auth: `organization/add-team-member { teamId, userId }` — the
83+
// team-side mirror of `sys_team_member.add_team_member`. That one sits on
84+
// the membership list's toolbar, where no team is in hand, so it asks for
85+
// one. Opened from a team, the record IS the team: its id rides as
86+
// `teamId` (the `sys_organization.invite_user` shape), and the dialog
87+
// asks only for the user.
88+
name: 'add_team_member',
89+
label: 'Add Member',
90+
icon: 'user-plus',
91+
locations: ['list_item', 'record_header'],
92+
type: 'api',
93+
target: '/api/v1/auth/organization/add-team-member',
94+
recordIdParam: 'teamId',
95+
// Same gates as the membership-list copy.
96+
requiresFeature: 'organization',
97+
requiresMembershipReach: 'add_team_member',
98+
successMessage: 'Team member added',
99+
refreshAfter: true,
100+
params: [
101+
// `user_id` is not a field of sys_team, so the param names its owner
102+
// through `objectOverride` — the sys_user lookup on the membership row.
103+
{ name: 'userId', field: 'user_id', objectOverride: 'sys_team_member', required: true },
104+
],
105+
},
81106
{
82107
// Better-auth: `organization/remove-team { teamId, organizationId? }`.
83108
// organizationId defaults to the caller's active org when omitted.

‎packages/platform-objects/src/platform-objects.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,7 @@ describe('feature-gate lowering matrix (#2874)', () => {
733733
['SysTeam', SysTeam, 'create_team', `${ADMINS} && ${ORG}`],
734734
['SysTeam', SysTeam, 'update_team', `${ADMINS} && ${ORG}`],
735735
['SysTeam', SysTeam, 'remove_team', `${ADMINS} && ${ORG}`],
736+
['SysTeam', SysTeam, 'add_team_member', `${ADMINS} && ${ORG}`],
736737
['SysTeamMember', SysTeamMember, 'add_team_member', `${ADMINS} && ${ORG}`],
737738
['SysTeamMember', SysTeamMember, 'remove_team_member', `${ADMINS} && ${ORG}`],
738739
// #2874 P2b — audit gates: capability-dependent actions that previously

‎packages/spec/src/kernel/public-auth-features.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ export const PUBLIC_AUTH_FEATURES = {
123123
'sys_team.actions.create_team',
124124
'sys_team.actions.update_team',
125125
'sys_team.actions.remove_team',
126+
'sys_team.actions.add_team_member',
126127
'sys_team_member.actions.add_team_member',
127128
'sys_team_member.actions.remove_team_member',
128129
// Fourth mirror of the email-invite entry, at the organization record

0 commit comments

Comments
 (0)