diff --git a/config.template.jsonc b/config.template.jsonc index df2bf4d7a..fb9f7a5df 100644 --- a/config.template.jsonc +++ b/config.template.jsonc @@ -273,7 +273,7 @@ // "emailBatchSeconds": 90 // }, - // ── Teams / workspaces ────────────────────────────────────────────── + // ── Teams / teams ────────────────────────────────────────────── // Off by default, and off is the state every existing install stays in. // The tables ship either way and sit inert; this gates whether `/teams` // is registered at all, so with it off the paths do not exist rather than diff --git a/src/backend/clients/database/migrations/sqlite/0077_teams.sql b/src/backend/clients/database/migrations/sqlite/0077_teams.sql index a08b434c4..ff2cb6a08 100644 --- a/src/backend/clients/database/migrations/sqlite/0077_teams.sql +++ b/src/backend/clients/database/migrations/sqlite/0077_teams.sql @@ -30,10 +30,10 @@ CREATE UNIQUE INDEX IF NOT EXISTS `idx_group_handle` -- mysql and postgres already index this column; sqlite does not. CREATE INDEX IF NOT EXISTS `idx_group_owner` ON `group` (`owner_user_id`); --- 1 = workspace-created, 0 = the workspace owner. Decides who pays, not who may read. +-- 1 = team-created, 0 = the team owner. Decides who pays, not who may read. ALTER TABLE `jct_user_group` ADD COLUMN `org_owned` INTEGER DEFAULT NULL; --- Covers "list this workspace's members"; the unique pair index lands in 0072. +-- Covers "list this team's members"; the unique pair index lands in 0072. CREATE INDEX IF NOT EXISTS `idx_jct_user_group_group` ON `jct_user_group` (`group_id`, `user_id`); diff --git a/src/backend/clients/database/migrations/sqlite/0079_team-audit-and-group-shares.sql b/src/backend/clients/database/migrations/sqlite/0079_team-audit-and-group-shares.sql index 73b226cda..65051fa1c 100644 --- a/src/backend/clients/database/migrations/sqlite/0079_team-audit-and-group-shares.sql +++ b/src/backend/clients/database/migrations/sqlite/0079_team-audit-and-group-shares.sql @@ -37,7 +37,7 @@ CREATE TABLE IF NOT EXISTS `audit_team_membership` ( FOREIGN KEY("actor_user_id") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE ); --- The admin's view: one workspace, newest first. +-- The admin's view: one team, newest first. CREATE INDEX IF NOT EXISTS `idx_audit_team_membership_group` ON `audit_team_membership` (`group_id_keep`, `id`); diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index 16bb03651..bb0e0e384 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -2108,7 +2108,7 @@ describe('AuthController grant flows', () => { makeReq( { app_uid: app.uid, - permission: `${namespace}:workspace:abc`, + permission: `${namespace}:team:abc`, }, { actor: issuerActor }, ), @@ -5319,7 +5319,7 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () => const permission = kvSharePermission( owner.uuid as string, 'os-global', - 'workspace:abc:', + 'team:abc:', ); await inCtx(ownerActor, () => server.services.permission.grantUserUserPermission( diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index 328ccb87e..66be0e0dc 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -4480,7 +4480,7 @@ export class AuthController extends PuterController { if (!existing) return null; // A provisioned account looks exactly like a claimable placeholder -- // no password, unconfirmed -- but claiming it hands a stranger that - // workspace's membership. + // team's membership. const orgSeat = await this.stores.team.getOrgSeat(existing.id); if ( existing.email_confirmed || diff --git a/src/backend/controllers/team/TeamController.http.test.ts b/src/backend/controllers/team/TeamController.http.test.ts index 4d222b506..081004896 100644 --- a/src/backend/controllers/team/TeamController.http.test.ts +++ b/src/backend/controllers/team/TeamController.http.test.ts @@ -49,8 +49,8 @@ describe('team endpoints over HTTP', () => { ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); - /** A workspace owned by `user`, with one provisioned member. */ - const makeWorkspace = async () => { + /** A team owned by `user`, with one provisioned member. */ + const makeTeam = async () => { const res = await call('POST', '/teams', env.users.user.token, { name: 'Acme', handle: randomHandle(), @@ -73,7 +73,7 @@ describe('team endpoints over HTTP', () => { // -- the owner-account gate -------------------------------------- - it('creates a workspace and reports the caller as its owner', async () => { + it('creates a team and reports the caller as its owner', async () => { const res = await call('POST', '/teams', env.users.user.token, { name: 'Acme Design', handle: randomHandle(), @@ -88,26 +88,33 @@ describe('team endpoints over HTTP', () => { }); it('returns 404, not 403, to a non-member', async () => { - const { team } = await makeWorkspace(); + const { team } = await makeTeam(); const res = await call( 'GET', `/teams/${team.uid}`, env.users.other.token, ); - // 403 would confirm the workspace exists. + // 403 would confirm the team exists. expect(res.status).toBe(404); }); it('refuses every administrative route to a member who is not the owner', async () => { - const { team } = await makeWorkspace(); - // Provisioned accounts have no password, so they cannot authenticate. - const other = await env.server.stores.user.getByUsername( - env.users.other.username, + const { team, memberUsername } = await makeTeam(); + // An activated seat: provisioning leaves the email unconfirmed, which + // `requireVerified` rejects, so an unactivated one cannot call at all. + const provisioned = await env.server.stores.user.getByUsername( + memberUsername, ); - await env.server.stores.team.addMember(team.uid, other!.id, { - orgOwned: true, + await env.server.stores.user.update(provisioned!.id, { + email_confirmed: 1, + requires_email_confirmation: 0, + requires_password_change: 0, }); + const seat = await env.server.stores.user.getByUsername(memberUsername); + const { token } = await env.server.services.auth.createSessionToken( + seat!, + ); for (const [method, path] of [ ['PUT', `/teams/${team.uid}`], @@ -118,25 +125,21 @@ describe('team endpoints over HTTP', () => { const res = await call( method, path, - env.users.other.token, + token, method === 'GET' ? undefined : { name: 'nope' }, ); expect(res.status, `${method} ${path}`).toBe(403); } // Still a member, so reads it is entitled to still work. - const readable = await call( - 'GET', - `/teams/${team.uid}`, - env.users.other.token, - ); + const readable = await call('GET', `/teams/${team.uid}`, token); expect(readable.status).toBe(200); }); // -- the org_owned guard ------------------------------------------ - it('refuses the workspace owner as the target of a member route', async () => { - const { team } = await makeWorkspace(); + it('refuses the team owner as the target of a member route', async () => { + const { team } = await makeTeam(); const res = await call( 'POST', @@ -147,7 +150,7 @@ describe('team endpoints over HTTP', () => { }); it('lists members with org_owned distinguishing the owner', async () => { - const { team, memberUsername } = await makeWorkspace(); + const { team, memberUsername } = await makeTeam(); const res = await call( 'GET', @@ -169,7 +172,7 @@ describe('team endpoints over HTTP', () => { // -- provisioning over the wire ----------------------------------- it('never returns the activation link to the administrator', async () => { - const { team } = await makeWorkspace(); + const { team } = await makeTeam(); const username = `secret_${Math.random().toString(36).slice(2, 9)}`; const res = await call( @@ -187,7 +190,7 @@ describe('team endpoints over HTTP', () => { }); it('refuses a taken username and offers alternatives', async () => { - const { team } = await makeWorkspace(); + const { team } = await makeTeam(); const res = await call( 'POST', @@ -210,8 +213,8 @@ describe('team endpoints over HTTP', () => { // -- audit -------------------------------------------------------- - it('exposes the workspace audit to the workspace owner only', async () => { - const { team } = await makeWorkspace(); + it('exposes the team audit to the team owner only', async () => { + const { team } = await makeTeam(); const mine = await call( 'GET', @@ -243,7 +246,7 @@ describe('team endpoints with teams_enabled off', () => { }); it('404s every team route, so no team code is reachable', async () => { - // Real workspace: 200 with the flag on, so a 404 means no route. + // Real team: 200 with the flag on, so a 404 means no route. const owner = await env.server.stores.user.getByUsername( env.users.user.username, ); @@ -285,7 +288,7 @@ describe('team endpoints with teams_enabled off', () => { const body = await res.text(); expect(body, `${method} ${path}`).not.toContain('team_not_found'); expect(body, `${method} ${path}`).not.toContain( - 'not_the_workspace_owner', + 'not_the_team_owner', ); } }); @@ -302,7 +305,7 @@ describe('team endpoints with teams_enabled off', () => { it('leaves the schema inert rather than absent', async () => { // The flag gates reachability, not DDL. await expect( - env.server.stores.team.getByHandle('no-such-workspace'), + env.server.stores.team.getByHandle('no-such-team'), ).resolves.toBeNull(); }); }); diff --git a/src/backend/controllers/team/TeamController.ts b/src/backend/controllers/team/TeamController.ts index 9d2a1c5de..8f7024b1c 100644 --- a/src/backend/controllers/team/TeamController.ts +++ b/src/backend/controllers/team/TeamController.ts @@ -47,7 +47,7 @@ const TEAM_READ_LIMIT = { key: 'user' as const, }; -/** What a workspace looks like on the wire. `id` stays internal. */ +/** What a team looks like on the wire. `id` stays internal. */ const toClientTeam = (team: TeamRow, isOwner: boolean) => ({ uid: team.uid, name: team.name, @@ -75,7 +75,7 @@ export class TeamController extends PuterController { const userId = this.#requireUserId(req); const body = this.#body(req); - const team = await this.services.team.createWorkspace(userId, { + const team = await this.services.team.createTeam(userId, { name: this.#requireString(body.name, 'name'), handle: body.handle === undefined || body.handle === null @@ -149,7 +149,7 @@ export class TeamController extends PuterController { }) async deleteTeam(req: Request, res: Response): Promise { const userId = this.#requireUserId(req); - await this.services.team.deleteWorkspace( + await this.services.team.deleteTeam( this.#param(req, 'uid'), userId, ); @@ -355,7 +355,7 @@ export class TeamController extends PuterController { } #notFound(): HttpError { - return new HttpError(404, 'Workspace not found', { + return new HttpError(404, 'Team not found', { legacyCode: 'team_not_found', }); } @@ -366,7 +366,7 @@ export class TeamController extends PuterController { this.#param(req, 'username'), ); if (!user) - throw new HttpError(404, 'Not an account of this workspace', { + throw new HttpError(404, 'Not an account of this team', { legacyCode: 'not_an_org_account', }); return user.id; diff --git a/src/backend/controllers/team/TeamIsolation.http.test.ts b/src/backend/controllers/team/TeamIsolation.http.test.ts index dcd9df4b4..2a37a02d0 100644 --- a/src/backend/controllers/team/TeamIsolation.http.test.ts +++ b/src/backend/controllers/team/TeamIsolation.http.test.ts @@ -18,7 +18,7 @@ */ /** - * A workspace manages accounts and cannot read them. Nobody writes that grant + * A team manages accounts and cannot read them. Nobody writes that grant * deliberately, but a new implicator or a widened actor would create it and no * other test would fail. Asserts outcomes, never that an implicator is absent. */ @@ -28,12 +28,13 @@ import { makeActor } from '../../core/actor.js'; import type { IConfig } from '../../types'; import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js'; -describe('a workspace cannot read its members data', () => { +describe('a team cannot read its members data', () => { let env: PuterTestEnv; let teamUid: string; let memberUsername: string; let memberFile: string; let ownerFile: string; + let memberToken: string; /** Written directly: these tests are about reading, not about writing. */ const makeFile = async (username: string, label: string) => { @@ -91,11 +92,26 @@ describe('a workspace cannot read its members data', () => { }); teamUid = ((await res.json()) as { uid: string }).uid; - memberUsername = env.users.other.username; + // A real provisioned seat, activated so it can authenticate. + memberUsername = `iso_${Math.random().toString(36).slice(2, 9)}`; + await env.server.services.team.provisionAccount( + teamUid, + (await env.server.stores.user.getByUsername( + env.users.user.username, + ))!.id, + { username: memberUsername, email: `${memberUsername}@test.local` }, + ); const member = await env.server.stores.user.getByUsername(memberUsername); - await env.server.stores.team.addMember(teamUid, member!.id, { - orgOwned: true, + await env.server.stores.user.update(member!.id, { + email_confirmed: 1, + requires_email_confirmation: 0, + requires_password_change: 0, }); + memberToken = ( + await env.server.services.auth.createSessionToken( + (await env.server.stores.user.getByUsername(memberUsername))!, + ) + ).token; memberFile = await makeFile(memberUsername, 'member'); ownerFile = await makeFile(env.users.user.username, 'owner'); @@ -107,13 +123,13 @@ describe('a workspace cannot read its members data', () => { // -- refused ------------------------------------------------------ - it('refuses the workspace owner a members file', async () => { + it('refuses the team owner a members file', async () => { const res = await stat(memberFile, env.users.user.token); // Administering, paying for, and reading an account are three things. expect(res.status).toBeGreaterThanOrEqual(400); }); - it('refuses the workspace owner a members home directory', async () => { + it('refuses the team owner a members home directory', async () => { const res = await readdir(`/${memberUsername}`, env.users.user.token); expect(res.status).toBeGreaterThanOrEqual(400); }); @@ -124,7 +140,7 @@ describe('a workspace cannot read its members data', () => { expect(res.status).toBeGreaterThanOrEqual(400); }); - it('grants no workspace-wide reach through the team routes', async () => { + it('grants no team-wide reach through the team routes', async () => { // There is no route to a member's files or KV, and none should appear. for (const path of [ `/teams/${teamUid}/files`, @@ -140,13 +156,13 @@ describe('a workspace cannot read its members data', () => { // -- allowed ------------------------------------------------------ - it('lets the workspace owner read its own files', async () => { + it('lets the team owner read its own files', async () => { const res = await stat(ownerFile, env.users.user.token); expect(res.status).toBe(200); }); it('lets a member read their own files', async () => { - const res = await stat(memberFile, env.users.other.token); + const res = await stat(memberFile, memberToken); expect(res.status).toBe(200); }); @@ -159,7 +175,7 @@ describe('a workspace cannot read its members data', () => { [memberFile], )) as { uuid: string }[]; - // Only the member's own grant changes, never workspace authority. + // Only the member's own grant changes, never team authority. const before = await stat(memberFile, env.users.user.token); expect(before.status).toBeGreaterThanOrEqual(400); @@ -190,10 +206,10 @@ describe('a workspace cannot read its members data', () => { ); // The member loses access to their own files... - const asMember = await stat(untouched, env.users.other.token); + const asMember = await stat(untouched, memberToken); expect(asMember.status).toBeGreaterThanOrEqual(400); - // ...and the workspace still does not gain it. + // ...and the team still does not gain it. const asOwner = await stat(untouched, env.users.user.token); expect(asOwner.status).toBeGreaterThanOrEqual(400); }); diff --git a/src/backend/core/http/HttpError.ts b/src/backend/core/http/HttpError.ts index a13580e7c..7c6fb08fd 100644 --- a/src/backend/core/http/HttpError.ts +++ b/src/backend/core/http/HttpError.ts @@ -56,7 +56,7 @@ export type LegacyErrorCodes = | 'account_is_not_verified' | 'app_or_api_token_required' | 'team_not_found' - | 'not_the_workspace_owner' + | 'not_the_team_owner' | 'not_an_org_account'; /** diff --git a/src/backend/services/team/TeamService.test.ts b/src/backend/services/team/TeamService.test.ts index f2ede4d4a..c0065ac74 100644 --- a/src/backend/services/team/TeamService.test.ts +++ b/src/backend/services/team/TeamService.test.ts @@ -41,9 +41,9 @@ describe('TeamService', () => { const freeHandle = () => `ws-${Math.random().toString(36).slice(2, 10)}`; - /** A workspace with the owner admitted and one provisioned member. */ - const makeWorkspace = async () => { - const team = await service.createWorkspace(owner.id, { + /** A team with the owner admitted and one provisioned member. */ + const makeTeam = async () => { + const team = await service.createTeam(owner.id, { name: 'Acme', handle: freeHandle(), }); @@ -67,7 +67,8 @@ describe('TeamService', () => { }; beforeAll(async () => { - server = await setupTestServer(); + // The policy and resolver are gated on the same flag as the routes. + server = await setupTestServer({ teams_enabled: true } as never); service = server.services.team; owner = await makeUser(); ownerUsername = (await server.stores.user.getById(owner.id))!.username; @@ -77,10 +78,10 @@ describe('TeamService', () => { await server?.shutdown(); }); - // -- creating a workspace ----------------------------------------- + // -- creating a team ----------------------------------------- - it('creates a workspace and admits its creator as the workspace owner', async () => { - const team = await service.createWorkspace(owner.id, { + it('creates a team and admits its creator as the team owner', async () => { + const team = await service.createTeam(owner.id, { name: 'Acme Design', handle: freeHandle(), }); @@ -95,12 +96,12 @@ describe('TeamService', () => { }); it('holds the owner invariant that no dialect can express', async () => { - const { team } = await makeWorkspace(); + const { team } = await makeTeam(); await expect(service.checkOwnerInvariant(team.uid)).resolves.toBe(true); }); it('breaks the invariant if a second account is admitted as the payer', async () => { - const { team } = await makeWorkspace(); + const { team } = await makeTeam(); const other = await makeUser(); await server.stores.team.addMember(team.uid, other.id, { orgOwned: false, @@ -112,15 +113,15 @@ describe('TeamService', () => { // -- authority ---------------------------------------------------- - it('refuses a member who is not the workspace owner', async () => { - const { team, member } = await makeWorkspace(); + it('refuses a member who is not the team owner', async () => { + const { team, member } = await makeTeam(); await expect( service.requireOwner(team.uid, member.id), ).rejects.toMatchObject({ statusCode: 403 }); }); it('gives a stranger 404 rather than 403, so it is not an oracle', async () => { - const { team } = await makeWorkspace(); + const { team } = await makeTeam(); const stranger = await makeUser(); await expect( service.requireMembership(team.uid, stranger.id), @@ -130,8 +131,8 @@ describe('TeamService', () => { ).rejects.toMatchObject({ statusCode: 404 }); }); - it('refuses the workspace owner as the target of a member route', async () => { - const { team } = await makeWorkspace(); + it('refuses the team owner as the target of a member route', async () => { + const { team } = await makeTeam(); await expect( service.requireOrgAccount(team.uid, owner.id), ).rejects.toMatchObject({ statusCode: 404 }); @@ -140,7 +141,7 @@ describe('TeamService', () => { // -- disable and re-enable ---------------------------------------- it('sets the column the request gate actually reads', async () => { - const { team, member } = await makeWorkspace(); + const { team, member } = await makeTeam(); await service.disableMember(team.uid, owner.id, member.id); @@ -148,11 +149,11 @@ describe('TeamService', () => { // `userProtected` rejects on `suspended`; the others gate nothing. expect(Boolean(row.suspended)).toBe(true); expect(row.suspended_at).toBeGreaterThan(0); - expect(row.suspended_reason).toBe('disabled_by_workspace'); + expect(row.suspended_reason).toBe('disabled_by_team'); }); it('drops the disabled account\'s sessions', async () => { - const { team, member } = await makeWorkspace(); + const { team, member } = await makeTeam(); await server.clients.db.write( 'INSERT INTO `sessions` (`uuid`, `user_id`) VALUES (?, ?)', [uuidv4(), member.id], @@ -175,7 +176,7 @@ describe('TeamService', () => { }); it('restores the account exactly as it was on re-enable', async () => { - const { team, member } = await makeWorkspace(); + const { team, member } = await makeTeam(); await service.disableMember(team.uid, owner.id, member.id); await service.enableMember(team.uid, owner.id, member.id); @@ -187,7 +188,7 @@ describe('TeamService', () => { }); it('leaves the disabled account\'s files alone', async () => { - const { team, member } = await makeWorkspace(); + const { team, member } = await makeTeam(); await server.clients.db.write( 'INSERT INTO `fsentries` (`uuid`, `name`, `user_id`, `modified`) VALUES (?, ?, ?, ?)', [uuidv4(), 'kept.txt', member.id, 0], @@ -202,8 +203,8 @@ describe('TeamService', () => { expect(Number(rows[0].n)).toBe(1); }); - it('refuses to disable the workspace owner', async () => { - const { team } = await makeWorkspace(); + it('refuses to disable the team owner', async () => { + const { team } = await makeTeam(); await expect( service.disableMember(team.uid, owner.id, owner.id), ).rejects.toMatchObject({ statusCode: 404 }); @@ -212,7 +213,7 @@ describe('TeamService', () => { }); it('refuses a disable ordered by someone who is not the owner', async () => { - const { team, member } = await makeWorkspace(); + const { team, member } = await makeTeam(); const other = await makeUser(); await server.stores.team.addMember(team.uid, other.id, { orgOwned: true, @@ -224,16 +225,16 @@ describe('TeamService', () => { expect(Boolean((await suspensionOf(member.id)).suspended)).toBe(false); }); - it('refuses to disable a member of another workspace', async () => { - const a = await makeWorkspace(); - const b = await makeWorkspace(); + it('refuses to disable a member of another team', async () => { + const a = await makeTeam(); + const b = await makeTeam(); await expect( service.disableMember(a.team.uid, owner.id, b.member.id), ).rejects.toMatchObject({ statusCode: 404 }); }); - it('refuses to lift a suspension the workspace did not impose', async () => { - const { team, member } = await makeWorkspace(); + it('refuses to lift a suspension the team did not impose', async () => { + const { team, member } = await makeTeam(); // What a platform abuse suspension looks like. await server.stores.user.update(member.id, { suspended: 1, @@ -251,7 +252,7 @@ describe('TeamService', () => { }); it('lifts its own suspension normally', async () => { - const { team, member } = await makeWorkspace(); + const { team, member } = await makeTeam(); await service.disableMember(team.uid, owner.id, member.id); await service.enableMember(team.uid, owner.id, member.id); @@ -261,8 +262,8 @@ describe('TeamService', () => { // -- provisioning ------------------------------------------------- - it('creates an account the workspace owns, pending a password change', async () => { - const { team } = await makeWorkspace(); + it('creates an account the team owns, pending a password change', async () => { + const { team } = await makeTeam(); const username = `prov_${Math.random().toString(36).slice(2, 9)}`; const result = await service.provisionAccount(team.uid, owner.id, { @@ -282,7 +283,7 @@ describe('TeamService', () => { }); it('gives the new account its default filesystem tree', async () => { - const { team } = await makeWorkspace(); + const { team } = await makeTeam(); const username = `tree_${Math.random().toString(36).slice(2, 9)}`; const result = await service.provisionAccount(team.uid, owner.id, { @@ -295,7 +296,7 @@ describe('TeamService', () => { }); it('returns a temporary password the admin delivers out of band', async () => { - const { team } = await makeWorkspace(); + const { team } = await makeTeam(); const username = `link_${Math.random().toString(36).slice(2, 9)}`; const result = await service.provisionAccount(team.uid, owner.id, { @@ -311,7 +312,7 @@ describe('TeamService', () => { }); it('writes nothing when the username is taken', async () => { - const { team } = await makeWorkspace(); + const { team } = await makeTeam(); const taken = await makeUser(); const before = await server.stores.team.listMembers(team.uid); @@ -322,7 +323,7 @@ describe('TeamService', () => { }), ).rejects.toMatchObject({ statusCode: 409 }); - // Checked before any row is written, so the workspace is untouched. + // Checked before any row is written, so the team is untouched. const after = await server.stores.team.listMembers(team.uid); expect(after.items).toHaveLength(before.items.length); }); @@ -340,8 +341,8 @@ describe('TeamService', () => { } }); - it('refuses provisioning by anyone but the workspace owner', async () => { - const { team, member } = await makeWorkspace(); + it('refuses provisioning by anyone but the team owner', async () => { + const { team, member } = await makeTeam(); await expect( service.provisionAccount(team.uid, member.id, { username: `nope_${Math.random().toString(36).slice(2, 9)}`, @@ -351,7 +352,7 @@ describe('TeamService', () => { }); it('re-issues a different credential each time', async () => { - const { team } = await makeWorkspace(); + const { team } = await makeTeam(); const username = `again_${Math.random().toString(36).slice(2, 9)}`; const result = await service.provisionAccount(team.uid, owner.id, { username, @@ -366,7 +367,7 @@ describe('TeamService', () => { }); it('refuses to re-issue once the member has chosen a password', async () => { - const { team } = await makeWorkspace(); + const { team } = await makeTeam(); const username = `done_${Math.random().toString(36).slice(2, 9)}`; const result = await service.provisionAccount(team.uid, owner.id, { username, @@ -382,7 +383,7 @@ describe('TeamService', () => { ).rejects.toMatchObject({ statusCode: 409 }); }); it('refuses an email that already belongs to an account', async () => { - const { team } = await makeWorkspace(); + const { team } = await makeTeam(); const existing = await makeUser(); const row = await server.stores.user.getById(existing.id); @@ -395,7 +396,7 @@ describe('TeamService', () => { }); it('refuses a username signup itself would reject', async () => { - const { team } = await makeWorkspace(); + const { team } = await makeTeam(); for (const username of ['has-hyphen', 'x'.repeat(46), 'admin']) { await expect( service.provisionAccount(team.uid, owner.id, { @@ -407,7 +408,7 @@ describe('TeamService', () => { }); it('refuses an invalid email', async () => { - const { team } = await makeWorkspace(); + const { team } = await makeTeam(); await expect( service.provisionAccount(team.uid, owner.id, { username: `bad_${Math.random().toString(36).slice(2, 9)}`, @@ -424,7 +425,7 @@ describe('TeamService', () => { } }); it('generates a different credential for every account', async () => { - const { team } = await makeWorkspace(); + const { team } = await makeTeam(); const seen = new Set(); for (let i = 0; i < 3; i++) { const u = `gen_${Math.random().toString(36).slice(2, 9)}`; @@ -439,7 +440,7 @@ describe('TeamService', () => { }); it('survives having no email transport, since the notice carries nothing', async () => { - const { team } = await makeWorkspace(); + const { team } = await makeTeam(); const u = `noem_${Math.random().toString(36).slice(2, 9)}`; // The credential is returned, so delivery failing loses nothing. @@ -452,7 +453,7 @@ describe('TeamService', () => { // -- audit -------------------------------------------------------- it('records provisioning, disabling and enabling as they happen', async () => { - const { team } = await makeWorkspace(); + const { team } = await makeTeam(); const username = `aud_${Math.random().toString(36).slice(2, 9)}`; const created = await service.provisionAccount(team.uid, owner.id, { username, @@ -475,7 +476,7 @@ describe('TeamService', () => { }); it('shows a member only their own entries', async () => { - const { team } = await makeWorkspace(); + const { team } = await makeTeam(); const a = `one_${Math.random().toString(36).slice(2, 9)}`; const b = `two_${Math.random().toString(36).slice(2, 9)}`; const first = await service.provisionAccount(team.uid, owner.id, { @@ -496,47 +497,47 @@ describe('TeamService', () => { }); it('keeps the audit from a member who is not the owner', async () => { - const { team, member } = await makeWorkspace(); + const { team, member } = await makeTeam(); await expect( service.listAudit(team.uid, member.id), ).rejects.toMatchObject({ statusCode: 403 }); }); - it('records a workspace deletion and survives the soft delete', async () => { - const { team } = await makeWorkspace(); - await service.deleteWorkspace(team.uid, owner.id); + it('records a team deletion and survives the soft delete', async () => { + const { team } = await makeTeam(); + await service.deleteTeam(team.uid, owner.id); // Gone from reads, but its owner can still read what happened. await expect(server.stores.team.getByUid(team.uid)).resolves.toBeNull(); const { items: entries } = await service.listAudit(team.uid, owner.id); expect(entries.map((e) => e.action)).toContain('delete_team'); }); - it('disables the accounts it created when the workspace is deleted', async () => { - const { team } = await makeWorkspace(); + it('disables the accounts it created when the team is deleted', async () => { + const { team } = await makeTeam(); const u = `del_${Math.random().toString(36).slice(2, 9)}`; const provisioned = await service.provisionAccount(team.uid, owner.id, { username: u, email: `${u}@test.local`, }); - await service.deleteWorkspace(team.uid, owner.id); + await service.deleteTeam(team.uid, owner.id); - // Otherwise they keep working, unreachable through a deleted workspace. + // Otherwise they keep working, unreachable through a deleted team. const row = await suspensionOf(provisioned.userId); expect(Boolean(row.suspended)).toBe(true); - expect(row.suspended_reason).toBe('disabled_by_workspace'); + expect(row.suspended_reason).toBe('disabled_by_team'); }); - it('leaves the workspace owner alone when its workspace is deleted', async () => { - const { team } = await makeWorkspace(); - await service.deleteWorkspace(team.uid, owner.id); + it('leaves the team owner alone when its team is deleted', async () => { + const { team } = await makeTeam(); + await service.deleteTeam(team.uid, owner.id); - // org_owned = 0, so it pays for itself and is not the workspace's to close. + // org_owned = 0, so it pays for itself and is not the team's to close. expect(Boolean((await suspensionOf(owner.id)).suspended)).toBe(false); }); it('pages the audit rather than truncating it', async () => { - const { team } = await makeWorkspace(); + const { team } = await makeTeam(); for (let i = 0; i < 2; i++) { const u = `pg_${Math.random().toString(36).slice(2, 9)}`; await service.provisionAccount(team.uid, owner.id, { @@ -553,4 +554,5 @@ describe('TeamService', () => { const second = await service.listAudit(team.uid, owner.id, { limit: 1 }); expect(second.items).toHaveLength(1); }); + }); diff --git a/src/backend/services/team/TeamService.ts b/src/backend/services/team/TeamService.ts index d7c8a2d1a..131bff8f1 100644 --- a/src/backend/services/team/TeamService.ts +++ b/src/backend/services/team/TeamService.ts @@ -53,7 +53,7 @@ export const generateTemporaryPassword = (length = 16): string => { }; /** Why an account was disabled. Free text in `0063`; this is the team one. */ -export const DISABLED_BY_WORKSPACE = 'disabled_by_workspace'; +export const DISABLED_BY_TEAM = 'disabled_by_team'; export class TeamService extends PuterService { // -- Authority ---- the whole authorization model ------------------ @@ -65,28 +65,25 @@ export class TeamService extends PuterService { ): Promise { const team = await this.stores.team.getByUid(teamUid); if (!team || !(await this.stores.team.isMember(teamUid, actorUserId))) { - throw new HttpError(404, 'Workspace not found', { + throw new HttpError(404, 'Team not found', { legacyCode: 'team_not_found', }); } return team; } - /** Authority is one test: the caller is the account named by the workspace. */ - async requireOwner( - teamUid: string, - actorUserId: number, - ): Promise { + /** Authority is one test: the caller is the account named by the team. */ + async requireOwner(teamUid: string, actorUserId: number): Promise { const team = await this.requireMembership(teamUid, actorUserId); if (team.owner_user_id !== actorUserId) { - throw new HttpError(403, 'Only the workspace owner can do that', { - legacyCode: 'not_the_workspace_owner', + throw new HttpError(403, 'Only the team owner can do that', { + legacyCode: 'not_the_team_owner', }); } return team; } - /** The workspace owner is never a valid target of a member route. */ + /** The team owner is never a valid target of a member route. */ async requireOrgAccount( teamUid: string, targetUserId: number, @@ -97,14 +94,14 @@ export class TeamService extends PuterService { ); // Tested explicitly, never inferred from NULL. if (!membership || Number(membership.org_owned) !== 1) { - throw new HttpError(404, 'Not an account of this workspace', { + throw new HttpError(404, 'Not an account of this team', { legacyCode: 'not_an_org_account', }); } return membership; } - // -- Workspace lifecycle ------------------------------------------ + // -- Team lifecycle ------------------------------------------ /** A rejected handle is 400, a taken one 409, never an unhandled 500. */ async assertHandleUsable(handle: string): Promise { @@ -142,8 +139,8 @@ export class TeamService extends PuterService { } } - /** Renames or re-handles a workspace, refusing an unusable handle. */ - async updateWorkspace( + /** Renames or re-handles a team, refusing an unusable handle. */ + async updateTeam( teamUid: string, actorUserId: number, changes: { name?: string; handle?: string | null }, @@ -155,15 +152,15 @@ export class TeamService extends PuterService { this.stores.team.update(teamUid, changes), ); if (!team) { - throw new HttpError(404, 'Workspace not found', { + throw new HttpError(404, 'Team not found', { legacyCode: 'team_not_found', }); } return team; } - /** Creates a workspace and admits its creator as the workspace owner. */ - async createWorkspace( + /** Creates a team and admits its creator as the team owner. */ + async createTeam( ownerUserId: number, input: { name: string; handle?: string | null }, ): Promise { @@ -188,7 +185,7 @@ export class TeamService extends PuterService { ); if (!admitted) { await this.stores.team.softDelete(team.uid); - throw new HttpError(500, 'Could not create the workspace', { + throw new HttpError(500, 'Could not create the team', { legacyCode: 'internal_error', }); } @@ -215,10 +212,10 @@ export class TeamService extends PuterService { } /** Soft delete disables the accounts it created; recovery is via support. */ - async deleteWorkspace(teamUid: string, actorUserId: number): Promise { + async deleteTeam(teamUid: string, actorUserId: number): Promise { const team = await this.requireOwner(teamUid, actorUserId); - // Otherwise they keep working, unreachable through a deleted workspace. + // Otherwise they keep working, unreachable through a deleted team. let page = await this.stores.team.listMembers(teamUid, { limit: 200 }); for (;;) { for (const member of page.items) { @@ -229,7 +226,7 @@ export class TeamService extends PuterService { userId: member.user_id, actorUserId, action: 'disable', - reason: 'workspace_deleted', + reason: 'team_deleted', }); } if (!page.cursor) break; @@ -248,13 +245,13 @@ export class TeamService extends PuterService { await this.stores.team.softDelete(teamUid); } - /** Workspace owner only. Readable after deletion -- that is the point of it. */ + /** Team owner only. Readable after deletion -- that is the point of it. */ async listAudit( teamUid: string, actorUserId: number, opts: { limit?: unknown; cursor?: string } = {}, ) { - const team = await this.#requireOwnedWorkspace(teamUid, actorUserId); + const team = await this.#requireOwnedTeam(teamUid, actorUserId); return this.#withUsernames( await this.stores.team.listAudit(team.id, opts), ); @@ -272,8 +269,8 @@ export class TeamService extends PuterService { ); } - /** Resolves a workspace the caller owns, soft-deleted or not. */ - async #requireOwnedWorkspace( + /** Resolves a team the caller owns, soft-deleted or not. */ + async #requireOwnedTeam( teamUid: string, actorUserId: number, ): Promise { @@ -283,7 +280,7 @@ export class TeamService extends PuterService { const deleted = await this.stores.team.getByUidIncludingDeleted(teamUid); if (!deleted || deleted.owner_user_id !== actorUserId) { - throw new HttpError(404, 'Workspace not found', { + throw new HttpError(404, 'Team not found', { legacyCode: 'team_not_found', }); } @@ -394,13 +391,13 @@ export class TeamService extends PuterService { await generateDefaultFsentries(this.clients.db, this.stores.user, user); - // Otherwise a vanished workspace leaves a real account nobody owns. + // Otherwise a vanished team leaves a real account nobody owns. const admitted = await this.stores.team.addMember(teamUid, user.id, { orgOwned: true, }); if (!admitted) { await this.services.userAccount.cascadeDelete(user.id); - throw new HttpError(409, 'That workspace is no longer available', { + throw new HttpError(409, 'That team is no longer available', { legacyCode: 'conflict', }); } @@ -469,7 +466,7 @@ export class TeamService extends PuterService { 'team_account_created', { username: user.username, - team_name: team.name ?? 'Your workspace', + team_name: team.name ?? 'Your team', }, ); // `sendRaw` returns null with no transport rather than throwing. @@ -515,10 +512,10 @@ export class TeamService extends PuterService { const user = await this.stores.user.getByProperty('id', targetUserId, { force: true, }); - // Only the workspace's own suspension; a platform one must not lift. + // Only the team's own suspension; a platform one must not lift. if ( user?.suspended && - user.suspended_reason !== DISABLED_BY_WORKSPACE + user.suspended_reason !== DISABLED_BY_TEAM ) { throw new HttpError(409, 'That account was suspended by Puter', { legacyCode: 'conflict', @@ -544,7 +541,7 @@ export class TeamService extends PuterService { await this.stores.user.update(userId, { suspended: 1, suspended_at: Math.floor(Date.now() / 1000), - suspended_reason: DISABLED_BY_WORKSPACE, + suspended_reason: DISABLED_BY_TEAM, }); await this.#dropSessions(userId); } diff --git a/src/backend/stores/permission/PermissionStore.test.ts b/src/backend/stores/permission/PermissionStore.test.ts index 01a6a8733..0c4a5216f 100644 --- a/src/backend/stores/permission/PermissionStore.test.ts +++ b/src/backend/stores/permission/PermissionStore.test.ts @@ -981,7 +981,7 @@ describe('PermissionStore', () => { const issuer = await makeUser(); const holder = await makeUser(); const other = await makeUser(); - const region = 'kv-share:owner:app:workspace:abc'; + const region = 'kv-share:owner:app:team:abc'; for (const user of [holder, other]) { await store.upsertUserUserPerm(user.id, issuer.id, region, {}); @@ -996,7 +996,7 @@ describe('PermissionStore', () => { await store.upsertUserUserPerm( holder.id, issuer.id, - 'kv-share:owner:app:workspace:abcdef', + 'kv-share:owner:app:team:abcdef', {}, ); @@ -1010,10 +1010,10 @@ describe('PermissionStore', () => { expect( ( await store.readLinkedUserUserPerms(holder.id, [ - 'kv-share:owner:app:workspace:abcdef', + 'kv-share:owner:app:team:abcdef', ]) ).map((row) => row.permission), - ).toEqual(['kv-share:owner:app:workspace:abcdef']); + ).toEqual(['kv-share:owner:app:team:abcdef']); // The same region granted to somebody else is a different grant. expect( await store.readLinkedUserUserPerms(other.id, [ diff --git a/src/backend/stores/team/TeamStore.test.ts b/src/backend/stores/team/TeamStore.test.ts index 0b7515b68..6bb89b237 100644 --- a/src/backend/stores/team/TeamStore.test.ts +++ b/src/backend/stores/team/TeamStore.test.ts @@ -83,7 +83,7 @@ describe('TeamStore', () => { // -- create and read ---------------------------------------------- - it('creates a workspace and reads it back by uid', async () => { + it('creates a team and reads it back by uid', async () => { const handle = freeHandle(); const created = await store.create({ ownerUserId: owner.id, @@ -104,7 +104,7 @@ describe('TeamStore', () => { }); }); - it('creates a workspace without a handle', async () => { + it('creates a team without a handle', async () => { const created = await store.create({ ownerUserId: owner.id, name: 'Unnamed', @@ -142,7 +142,7 @@ describe('TeamStore', () => { ).rejects.toThrow(/unique|duplicate/iu); }); - it('returns null for a uid that is not a workspace', async () => { + it('returns null for a uid that is not a team', async () => { await expect(store.getByUid(uuidv4())).resolves.toBeNull(); await expect(store.getByHandle(freeHandle())).resolves.toBeNull(); }); @@ -168,7 +168,7 @@ describe('TeamStore', () => { // -- update ------------------------------------------------------- - it('renames a workspace without changing its uid', async () => { + it('renames a team without changing its uid', async () => { const created = await store.create({ ownerUserId: owner.id, name: 'Before', @@ -214,7 +214,7 @@ describe('TeamStore', () => { // -- soft delete -------------------------------------------------- - it('excludes a soft-deleted workspace from reads', async () => { + it('excludes a soft-deleted team from reads', async () => { const handle = freeHandle(); const created = await store.create({ ownerUserId: owner.id, @@ -251,7 +251,7 @@ describe('TeamStore', () => { }); }); - it('keeps `name` on a soft-deleted workspace so history still reads', async () => { + it('keeps `name` on a soft-deleted team so history still reads', async () => { const created = await store.create({ ownerUserId: owner.id, name: 'Remembered', @@ -313,7 +313,7 @@ describe('TeamStore', () => { await expect(store.isMember(team.uid, member.id)).resolves.toBe(true); }); - it('distinguishes the workspace owner by org_owned', async () => { + it('distinguishes the team owner by org_owned', async () => { const team = await store.create({ ownerUserId: owner.id, name: 'Payer', @@ -326,6 +326,41 @@ describe('TeamStore', () => { expect(Boolean(row?.org_owned)).toBe(false); }); + it('refuses to make an account that already has a password a seat', async () => { + const team = await store.create({ + ownerUserId: owner.id, + name: 'Adopt', + handle: freeHandle(), + }); + // Provisioning creates the row password-less and sets one afterwards, + // so a password is what distinguishes an account that predates us. + const existing = await makeUser(); + await server.stores.user.update(existing.id, { password: 'hashed' }); + + await expect( + store.addMember(team.uid, existing.id, { orgOwned: true }), + ).rejects.toThrow(/cannot make an existing account a team seat/); + await expect(store.isMember(team.uid, existing.id)).resolves.toBe( + false, + ); + }); + + it('still admits an account that has a password as the owner', async () => { + const team = await store.create({ + ownerUserId: owner.id, + name: 'Payer2', + handle: freeHandle(), + }); + // The guard is about seats: the owner joins their own team with a + // password already set, and must not be caught by it. + const payer = await makeUser(); + await server.stores.user.update(payer.id, { password: 'hashed' }); + + await expect( + store.addMember(team.uid, payer.id, { orgOwned: false }), + ).resolves.toBe(true); + }); + it('treats a repeated add as a no-op rather than a duplicate', async () => { const team = await store.create({ ownerUserId: owner.id, @@ -355,7 +390,7 @@ describe('TeamStore', () => { await expect(store.isMember(team.uid, member.id)).resolves.toBe(false); }); - it('scopes membership to the workspace asked for', async () => { + it('scopes membership to the team asked for', async () => { const a = await store.create({ ownerUserId: owner.id, name: 'A', @@ -373,7 +408,7 @@ describe('TeamStore', () => { expect((await store.listMembers(b.uid)).items).toEqual([]); }); - it('lists the workspaces a user belongs to', async () => { + it('lists the teams a user belongs to', async () => { const member = await makeUser(); const a = await store.create({ ownerUserId: owner.id, @@ -392,7 +427,7 @@ describe('TeamStore', () => { 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 () => { + it('drops a soft-deleted team from the user\'s list', async () => { const member = await makeUser(); const team = await store.create({ ownerUserId: owner.id, @@ -452,7 +487,7 @@ describe('TeamStore', () => { name: 'Capped', handle: freeHandle(), }); - // An empty workspace satisfies any cap, so seed enough to page. + // An empty team 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 }); diff --git a/src/backend/stores/team/TeamStore.ts b/src/backend/stores/team/TeamStore.ts index 4616d8019..14810fa01 100644 --- a/src/backend/stores/team/TeamStore.ts +++ b/src/backend/stores/team/TeamStore.ts @@ -26,7 +26,7 @@ import { } from '../../util/pagination.js'; import { PuterStore } from '../types'; -/** A workspace: a `group` row with `kind = 'team'`. */ +/** A team: a `group` row with `kind = 'team'`. */ export interface TeamRow { id: number; uid: string; @@ -41,7 +41,7 @@ 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. */ +/** A seat, joined to the team that pays for it. */ export interface OrgSeatRow { id: number; user_id: number; @@ -56,11 +56,12 @@ export interface TeamMemberRow { user_id: number; group_id: number; username: string; + uuid: string; org_owned: number; created_at: string; } -/** One entry in the insert-only record of what a workspace did to an account. */ +/** One entry in the insert-only record of what a team did to an account. */ export interface TeamAuditRow { id: number; user_id_keep: number; @@ -129,8 +130,8 @@ const RESERVED_HANDLES = new Set([ 'trust', 'trust-safety', 'verify', - 'workspace', - 'workspaces', + 'team', + 'teams', ]); export type HandleRejection = @@ -170,7 +171,7 @@ export class TeamStore extends PuterStore { // -- Reads -------------------------------------------------------- - /** The workspace with this uid, or null. Soft-deleted ones are excluded. */ + /** The team with this uid, or null. Soft-deleted ones are excluded. */ async getByUid(uid: string): Promise { const rows = await this.clients.db.read( `SELECT * FROM \`group\` WHERE \`uid\` = ? AND ${this.#live()}`, @@ -179,7 +180,7 @@ export class TeamStore extends PuterStore { return (rows[0] as unknown as TeamRow) ?? null; } - /** Includes soft-deleted rows, so an audit survives its workspace. */ + /** Includes soft-deleted rows, so an audit survives its team. */ async getByUidIncludingDeleted(uid: string): Promise { const rows = await this.clients.db.read( 'SELECT * FROM `group` WHERE `uid` = ? AND `kind` = ?', @@ -202,7 +203,7 @@ export class TeamStore extends PuterStore { return (await this.getByHandle(handle)) === null; } - /** Workspaces this user owns, oldest first. */ + /** Teams this user owns, oldest first. */ async listByOwner(ownerUserId: number): Promise { const rows = await this.clients.db.read( `SELECT * FROM \`group\` WHERE \`owner_user_id\` = ? AND ${this.#live()} ORDER BY \`id\``, @@ -241,7 +242,7 @@ export class TeamStore extends PuterStore { return created; } - /** Null when no live workspace has that uid. `handle: null` releases it. */ + /** Null when no live team has that uid. `handle: null` releases it. */ async update( uid: string, changes: { name?: string; handle?: string | null }, @@ -271,7 +272,6 @@ export class TeamStore extends PuterStore { ); return this.getByUid(uid); } - /** Releases the handle, since nothing addresses by it; keeps `name`. */ async softDelete(uid: string): Promise { const result = await this.clients.db.write( @@ -284,14 +284,14 @@ export class TeamStore extends PuterStore { // -- Membership ---- sole writer of team rows ----------------------- - /** The membership row for this user in this workspace, or null. */ + /** The membership row for this user in this team, or null. */ async getMembership( teamUid: string, userId: number, ): Promise { 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 ' + + 'ug.`created_at`, u.`username`, u.`uuid` 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()}`, @@ -300,12 +300,12 @@ export class TeamStore extends PuterStore { return (rows[0] as unknown as TeamMemberRow) ?? null; } - /** Whether this user belongs to this workspace. */ + /** Whether this user belongs to this team. */ async isMember(teamUid: string, userId: number): Promise { return (await this.getMembership(teamUid, userId)) !== null; } - /** A workspace's members, keyset-paginated on `id` per doc/pagination.md. */ + /** A team's members, keyset-paginated on `id` per doc/pagination.md. */ async listMembers( teamUid: string, opts: { limit?: unknown; cursor?: string } = {}, @@ -319,7 +319,7 @@ export class TeamStore extends PuterStore { // 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 ' + + 'ug.`created_at`, u.`username`, u.`uuid` 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()}` + @@ -338,7 +338,7 @@ export class TeamStore extends PuterStore { return { items, cursor }; } - /** Workspaces this user belongs to, oldest first. */ + /** Teams this user belongs to, oldest first. */ async listTeamsForUser(userId: number): Promise { const rows = await this.clients.db.read( 'SELECT g.* FROM `group` g ' + @@ -349,12 +349,23 @@ export class TeamStore extends PuterStore { return rows as unknown as TeamRow[]; } - /** `orgOwned` decides who pays: 1 workspace-created, 0 the workspace owner. */ + /** `orgOwned` decides who pays: 1 team-created, 0 the team owner. */ async addMember( teamUid: string, userId: number, opts: { orgOwned: boolean }, ): Promise { + // A seat is created, never adopted; a password means it predates us. + if (opts.orgOwned) { + const rows = (await this.clients.db.read( + 'SELECT 1 AS hit FROM `user` WHERE `id` = ? AND `password` IS NOT NULL LIMIT 1', + [userId], + )) as { hit: number }[]; + if (rows.length > 0) { + throw new Error('cannot make an existing account a team seat'); + } + } + // Kind-filtered subquery, so a non-team uid inserts nothing. const result = await this.clients.db.write( `${this.clients.db.insertIgnoreInto('jct_user_group')} ` + @@ -380,7 +391,7 @@ export class TeamStore extends PuterStore { // -- Audit ---- insert-only; no update or delete path exists -------- - /** Records something the workspace did to an account. */ + /** Records something the team did to an account. */ async appendAudit(entry: { teamId: number; userId: number; @@ -404,7 +415,7 @@ export class TeamStore extends PuterStore { ); } - /** The whole workspace's audit, newest first, keyset-paginated on `id`. */ + /** The whole team's audit, newest first, keyset-paginated on `id`. */ async listAudit( teamId: number, opts: { limit?: unknown; cursor?: string } = {}, @@ -412,7 +423,7 @@ export class TeamStore extends PuterStore { return this.#pageAudit('`group_id_keep` = ?', [teamId], opts); } - /** One member's own entries. Scoped by user, not by workspace. */ + /** One member's own entries. Scoped by user, not by team. */ async listAuditForUser( teamId: number, userId: number, @@ -465,7 +476,7 @@ export class TeamStore extends PuterStore { return result.anyRowsAffected; } - /** The workspace seat this user is, if any. Soft-deleted workspaces count. */ + /** The team seat this user is, if any. Soft-deleted teams count. */ async getOrgSeat(userId: number): Promise { const rows = (await this.clients.db.read( 'SELECT ug.`id`, ug.`user_id`, u.`uuid`, u.`username`, ' + diff --git a/src/backend/types.ts b/src/backend/types.ts index 935e5b7c2..a58a7772b 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -683,8 +683,8 @@ interface IConfigOptional { */ pub_port: number; /** - * Teams and workspaces. Off means `/teams` 404s and the schema is inert, so - * the tables can ship to production before anything can create a workspace. + * Teams and teams. Off means `/teams` 404s and the schema is inert, so + * the tables can ship to production before anything can create a team. * It is also the backout: turning it off removes the feature without * touching data. */