feat: add team membership reads and writes to TeamStore

Membership management for workspaces: addMember, removeMember,
getMembership, isMember, listMembers and listTeamsForUser. The permission
scan is untouched -- readUserGroupPerms already joins jct_user_group and
resolves group grants; this is the management side.

Resolves the ticket's "do not leave two writers" by splitting domains and
enforcing the split in SQL rather than by convention. Every existing caller
of GroupStore targets a seeded system group -- ADMIN_GROUP_UID,
default_user_group, default_temp_group -- never a team, so the two stores
were already disjoint in practice. GroupStore.addUsers/removeUsers now carry
`AND kind IS NULL`, making a team uid a no-op there, which costs no extra
query because it folds into the existing subquery and matches how addUsers
already treats an unknown username. TeamStore's writes select group_id from
a kind-filtered subquery, so neither store can reach the other's rows.

org_owned is written here but never accepted from a request; TeamService
sets it at provisioning and workspace creation only.

listMembers is keyset-paginated on id per doc/pagination.md, using the
shared cursor and limit helpers and fetching one row past the limit to
decide whether a cursor is warranted.

Passes 1/0 for org_owned rather than db.booleanValue, which yields a real
boolean on postgres and is rejected by the smallint column there -- sqlite
accepted it silently.
This commit is contained in:
Juan Castro
2026-09-03 15:57:16 -04:00
parent 37cb11df8c
commit 8b4e259421
4 changed files with 349 additions and 20 deletions
@@ -158,4 +158,31 @@ describe('GroupStore', () => {
expect(await memberUsernames(a)).toEqual([]);
expect(await memberUsernames(b)).toEqual([member.username]);
});
// -- the boundary with TeamStore ----------------------------------
it('refuses to add a member to a team, leaving that to TeamStore', async () => {
const uid = uuidv4();
await server.clients.db.write(
'INSERT INTO `group` (`uid`, `owner_user_id`, `kind`, `name`, `extra`, `metadata`) ' +
'VALUES (?, ?, ?, ?, ?, ?)',
[uid, owner.id, 'team', 'Not Yours', '{}', '{}'],
);
// Two writers produced the duplicates; the split is enforced in SQL.
await store.addUsers(uid, [member.username]);
expect(await memberUsernames(uid)).toEqual([]);
});
it('refuses to remove a member from a team', async () => {
const uid = uuidv4();
await server.clients.db.write(
'INSERT INTO `group` (`uid`, `owner_user_id`, `kind`, `name`, `extra`, `metadata`) ' +
'VALUES (?, ?, ?, ?, ?, ?)',
[uid, owner.id, 'team', 'Also Not Yours', '{}', '{}'],
);
await server.stores.team.addMember(uid, member.id, { orgOwned: true });
await store.removeUsers(uid, [member.username]);
expect(await memberUsernames(uid)).toEqual([member.username]);
});
});
+5 -12
View File
@@ -33,36 +33,29 @@ import { PuterStore } from '../types';
* which joins the junction table itself.
*/
export class GroupStore extends PuterStore {
/**
* Adds users (by username) to the group identified by `uid`. No-op if
* `usernames` is empty, and for a user who is already a member.
*/
/** Adds users to a seeded system group. No-op for a team or a member. */
async addUsers(uid: string, usernames: string[]): Promise<void> {
if (usernames.length === 0) return;
const placeholders = `(${usernames.map(() => '?').join(', ')})`;
// Ignore conflicts on the unique pair index from 0072; re-adding a member
// was a duplicate row before it, and would raise without this.
// Ignore conflicts on the unique pair index from 0072.
await this.clients.db.write(
`${this.clients.db.insertIgnoreInto('jct_user_group')} ` +
'(`user_id`, `group_id`) ' +
'SELECT u.id, g.id FROM `user` u ' +
'JOIN (SELECT id FROM `group` WHERE uid = ?) g ON 1 = 1 ' +
'JOIN (SELECT id FROM `group` WHERE uid = ? AND kind IS NULL) g ON 1 = 1 ' +
`WHERE u.username IN ${placeholders}` +
this.clients.db.insertIgnoreSuffix(),
[uid, ...usernames],
);
}
/**
* Removes users (by username) from the group identified by `uid`. No-op if
* `usernames` is empty.
*/
/** Removes users from a seeded system group. No-op for a team. */
async removeUsers(uid: string, usernames: string[]): Promise<void> {
if (usernames.length === 0) return;
const placeholders = `(${usernames.map(() => '?').join(', ')})`;
await this.clients.db.write(
'DELETE FROM `jct_user_group` ' +
'WHERE `group_id` = (SELECT id FROM `group` WHERE uid = ?) ' +
'WHERE `group_id` = (SELECT id FROM `group` WHERE uid = ? AND kind IS NULL) ' +
'AND `user_id` IN (' +
'SELECT u.id FROM `user` u ' +
`WHERE u.username IN ${placeholders})`,
+174
View File
@@ -292,4 +292,178 @@ describe('TeamStore', () => {
});
expect(team.name).toBe('Padded');
});
// -- membership ---------------------------------------------------
it('adds a member and reads the membership back', async () => {
const team = await store.create({
ownerUserId: owner.id,
name: 'Members',
handle: freeHandle(),
});
const member = await makeUser();
await expect(
store.addMember(team.uid, member.id, { orgOwned: true }),
).resolves.toBe(true);
const row = await store.getMembership(team.uid, member.id);
expect(row).toMatchObject({ user_id: member.id, group_id: team.id });
expect(Boolean(row?.org_owned)).toBe(true);
await expect(store.isMember(team.uid, member.id)).resolves.toBe(true);
});
it('distinguishes the workspace owner by org_owned', async () => {
const team = await store.create({
ownerUserId: owner.id,
name: 'Payer',
handle: freeHandle(),
});
await store.addMember(team.uid, owner.id, { orgOwned: false });
const row = await store.getMembership(team.uid, owner.id);
// Decides who pays, not who may read.
expect(Boolean(row?.org_owned)).toBe(false);
});
it('treats a repeated add as a no-op rather than a duplicate', async () => {
const team = await store.create({
ownerUserId: owner.id,
name: 'Once',
handle: freeHandle(),
});
const member = await makeUser();
await store.addMember(team.uid, member.id, { orgOwned: true });
await store.addMember(team.uid, member.id, { orgOwned: true });
const page = await store.listMembers(team.uid);
expect(page.items.filter((m) => m.user_id === member.id)).toHaveLength(1);
});
it('removes a member and reports whether there was one', async () => {
const team = await store.create({
ownerUserId: owner.id,
name: 'Leaving',
handle: freeHandle(),
});
const member = await makeUser();
await store.addMember(team.uid, member.id, { orgOwned: true });
await expect(store.removeMember(team.uid, member.id)).resolves.toBe(true);
await expect(store.removeMember(team.uid, member.id)).resolves.toBe(false);
await expect(store.isMember(team.uid, member.id)).resolves.toBe(false);
});
it('scopes membership to the workspace asked for', async () => {
const a = await store.create({
ownerUserId: owner.id,
name: 'A',
handle: freeHandle(),
});
const b = await store.create({
ownerUserId: owner.id,
name: 'B',
handle: freeHandle(),
});
const member = await makeUser();
await store.addMember(a.uid, member.id, { orgOwned: true });
await expect(store.isMember(b.uid, member.id)).resolves.toBe(false);
expect((await store.listMembers(b.uid)).items).toEqual([]);
});
it('lists the workspaces a user belongs to', async () => {
const member = await makeUser();
const a = await store.create({
ownerUserId: owner.id,
name: 'One',
handle: freeHandle(),
});
const b = await store.create({
ownerUserId: owner.id,
name: 'Two',
handle: freeHandle(),
});
await store.addMember(a.uid, member.id, { orgOwned: true });
await store.addMember(b.uid, member.id, { orgOwned: true });
const teams = await store.listTeamsForUser(member.id);
expect(teams.map((t) => t.uid).sort()).toEqual([a.uid, b.uid].sort());
});
it('drops a soft-deleted workspace from the user\'s list', async () => {
const member = await makeUser();
const team = await store.create({
ownerUserId: owner.id,
name: 'Gone',
handle: freeHandle(),
});
await store.addMember(team.uid, member.id, { orgOwned: true });
await store.softDelete(team.uid);
expect(await store.listTeamsForUser(member.id)).toEqual([]);
await expect(store.isMember(team.uid, member.id)).resolves.toBe(false);
});
it('refuses to add a member to a group that is not a team', async () => {
const [seeded] = (await server.clients.db.read(
'SELECT `uid` FROM `group` WHERE `kind` IS NULL LIMIT 1',
)) as { uid: string }[];
const member = await makeUser();
await expect(
store.addMember(seeded.uid, member.id, { orgOwned: true }),
).resolves.toBe(false);
});
// -- pagination ---------------------------------------------------
it('pages members on `id` and stops when the set is exhausted', async () => {
const team = await store.create({
ownerUserId: owner.id,
name: 'Paged',
handle: freeHandle(),
});
const ids: number[] = [];
for (let i = 0; i < 5; i++) {
const m = await makeUser();
ids.push(m.id);
await store.addMember(team.uid, m.id, { orgOwned: true });
}
const seen: number[] = [];
let cursor: string | undefined;
let pages = 0;
do {
const page = await store.listMembers(team.uid, { limit: 2, cursor });
seen.push(...page.items.map((m) => m.user_id));
cursor = page.cursor;
pages++;
} while (cursor && pages < 10);
expect(seen.sort()).toEqual([...ids].sort());
expect(pages).toBe(3);
});
it('caps the page size rather than trusting the caller', async () => {
const team = await store.create({
ownerUserId: owner.id,
name: 'Capped',
handle: freeHandle(),
});
// An empty workspace satisfies any cap, so seed enough to page.
for (let i = 0; i < 3; i++) {
const m = await makeUser();
await store.addMember(team.uid, m.id, { orgOwned: true });
}
const capped = await store.listMembers(team.uid, { limit: 100_000 });
expect(capped.items).toHaveLength(3);
expect(capped.cursor).toBeUndefined();
const paged = await store.listMembers(team.uid, { limit: 2 });
expect(paged.items).toHaveLength(2);
expect(paged.cursor).toBeTruthy();
});
});
+143 -8
View File
@@ -18,6 +18,12 @@
*/
import { v4 as uuidv4 } from 'uuid';
import {
encodeCursor,
decodeCursor,
normalizeLimit,
type PageResult,
} from '../../util/pagination.js';
import { PuterStore } from '../types';
/** A workspace: a `group` row with `kind = 'team'`. */
@@ -34,6 +40,30 @@ export interface TeamRow {
export const TEAM_KIND = 'team';
/** A membership row, joined to the member's username. */
/** A seat, joined to the workspace that pays for it. */
export interface OrgSeatRow {
id: number;
user_id: number;
uuid: string;
username: string;
team_uid: string;
owner_user_id: number;
}
export interface TeamMemberRow {
id: number;
user_id: number;
group_id: number;
username: string;
org_owned: number;
created_at: string;
}
/** Default and ceiling for `listMembers`, matching the other paginated stores. */
export const MEMBER_PAGE_SIZE = 50;
export const MEMBER_PAGE_CAP = 200;
/** Longest handle mysql can store — `varchar(64)` in mysql_mig_26. */
export const HANDLE_MAX_LENGTH = 64;
export const HANDLE_MIN_LENGTH = 3;
@@ -113,10 +143,7 @@ export const checkHandle = (handle: string): HandleRejection | null => {
};
export class TeamStore extends PuterStore {
/**
* Postgres indexes `lower(handle)`; the others are case-insensitive
* already.
*/
/** Postgres indexes lower(handle); the others are already insensitive. */
#handleMatch(): string {
return this.clients.db.case({
postgres: 'lower(`handle`) = lower(?)',
@@ -224,10 +251,7 @@ export class TeamStore extends PuterStore {
return this.getByUid(uid);
}
/**
* Releases the handle since nothing addresses by it; keeps `name` for
* history.
*/
/** Releases the handle, since nothing addresses by it; keeps `name`. */
async softDelete(uid: string): Promise<boolean> {
const result = await this.clients.db.write(
`UPDATE \`group\` SET \`deleted_at\` = CURRENT_TIMESTAMP, \`handle\` = NULL ` +
@@ -236,4 +260,115 @@ export class TeamStore extends PuterStore {
);
return result.anyRowsAffected;
}
// -- Membership ---- sole writer of team rows -----------------------
/** The membership row for this user in this workspace, or null. */
async getMembership(
teamUid: string,
userId: number,
): Promise<TeamMemberRow | null> {
const rows = await this.clients.db.read(
'SELECT ug.`id`, ug.`user_id`, ug.`group_id`, ug.`org_owned`, ' +
'ug.`created_at`, u.`username` FROM `jct_user_group` ug ' +
'JOIN `user` u ON u.`id` = ug.`user_id` ' +
'JOIN `group` g ON g.`id` = ug.`group_id` ' +
`WHERE g.\`uid\` = ? AND ug.\`user_id\` = ? AND g.${this.#live()}`,
[teamUid, userId, TEAM_KIND],
);
return (rows[0] as unknown as TeamMemberRow) ?? null;
}
/** Whether this user belongs to this workspace. */
async isMember(teamUid: string, userId: number): Promise<boolean> {
return (await this.getMembership(teamUid, userId)) !== null;
}
/** A workspace's members, keyset-paginated on `id` per doc/pagination.md. */
async listMembers(
teamUid: string,
opts: { limit?: unknown; cursor?: string } = {},
): Promise<PageResult<TeamMemberRow>> {
const limit =
normalizeLimit(opts.limit, { cap: MEMBER_PAGE_CAP }) ??
MEMBER_PAGE_SIZE;
const page = decodeCursor(opts.cursor, 'team member cursor');
const after = typeof page?.id === 'number' ? page.id : null;
// One row past the limit is how we know a further page exists.
const rows = (await this.clients.db.read(
'SELECT ug.`id`, ug.`user_id`, ug.`group_id`, ug.`org_owned`, ' +
'ug.`created_at`, u.`username` FROM `jct_user_group` ug ' +
'JOIN `user` u ON u.`id` = ug.`user_id` ' +
'JOIN `group` g ON g.`id` = ug.`group_id` ' +
`WHERE g.\`uid\` = ? AND g.${this.#live()}` +
(after === null ? '' : ' AND ug.`id` > ?') +
' ORDER BY ug.`id` LIMIT ?',
after === null
? [teamUid, TEAM_KIND, limit + 1]
: [teamUid, TEAM_KIND, after, limit + 1],
)) as unknown as TeamMemberRow[];
const items = rows.slice(0, limit);
const cursor =
rows.length > limit
? encodeCursor({ id: items[items.length - 1].id })
: undefined;
return { items, cursor };
}
/** Workspaces this user belongs to, oldest first. */
async listTeamsForUser(userId: number): Promise<TeamRow[]> {
const rows = await this.clients.db.read(
'SELECT g.* FROM `group` g ' +
'JOIN `jct_user_group` ug ON ug.`group_id` = g.`id` ' +
`WHERE ug.\`user_id\` = ? AND g.${this.#live()} ORDER BY g.\`id\``,
[userId, TEAM_KIND],
);
return rows as unknown as TeamRow[];
}
/** `orgOwned` decides who pays: 1 workspace-created, 0 the workspace owner. */
async addMember(
teamUid: string,
userId: number,
opts: { orgOwned: boolean },
): Promise<boolean> {
// Kind-filtered subquery, so a non-team uid inserts nothing.
const result = await this.clients.db.write(
`${this.clients.db.insertIgnoreInto('jct_user_group')} ` +
'(`user_id`, `group_id`, `org_owned`) ' +
'SELECT ?, g.`id`, ? FROM `group` g ' +
`WHERE g.\`uid\` = ? AND g.${this.#live()}` +
this.clients.db.insertIgnoreSuffix(),
// Not `booleanValue`: it yields a real boolean on postgres.
[userId, opts.orgOwned ? 1 : 0, teamUid, TEAM_KIND],
);
return result.anyRowsAffected;
}
/** Removes a member, returning whether a row was there to remove. */
async removeMember(teamUid: string, userId: number): Promise<boolean> {
const result = await this.clients.db.write(
'DELETE FROM `jct_user_group` WHERE `user_id` = ? AND `group_id` = ' +
`(SELECT \`id\` FROM \`group\` WHERE \`uid\` = ? AND ${this.#live()})`,
[userId, teamUid, TEAM_KIND],
);
return result.anyRowsAffected;
}
/** The workspace seat this user is, if any. Soft-deleted workspaces count. */
async getOrgSeat(userId: number): Promise<OrgSeatRow | null> {
const rows = (await this.clients.db.read(
'SELECT ug.`id`, ug.`user_id`, u.`uuid`, u.`username`, ' +
'g.`uid` AS `team_uid`, g.`owner_user_id` ' +
'FROM `jct_user_group` ug ' +
'JOIN `user` u ON u.`id` = ug.`user_id` ' +
'JOIN `group` g ON g.`id` = ug.`group_id` ' +
'WHERE ug.`user_id` = ? AND ug.`org_owned` = 1 ' +
'AND g.`kind` = ? ORDER BY g.`id` LIMIT 1',
[userId, TEAM_KIND],
)) as unknown as OrgSeatRow[];
return rows[0] ?? null;
}
}