feat: a seat is created, never adopted (#3720)

`addMember` refuses to turn an account that already has a password into a
team seat. No service path did this — `provisionAccount` always creates —
but the store permitted it, and the design rules out existing accounts joining
a team.

Provisioning passes the guard because it admits the account before setting its
temporary password.

There is no bypass parameter. Both HTTP suites now provision a real seat and
authenticate as it, using the same token-minting the harness uses for
`POST /login`. That surfaced something worth knowing: an unactivated seat
cannot call the API at all. Provisioning leaves `requires_email_confirmation`
set and `requireVerified` rejects it, so the suites activate the seat first —
which is the state a member is actually in when making requests.

`listMembers` and `getMembership` also return `u.uuid`, which the billing
events need in order to name the account without a second lookup.
This commit is contained in:
Juan Fernando Castro authored and GitHub committed 2026-09-08 16:45:16 -04:00
1 parent 84368f8d02
commit 00afea1285
15 files changed
+246 -182

No files matched your search

+1 -1
View File
@@ -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
@@ -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`);
@@ -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`);
@@ -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(
@@ -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 ||
@@ -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();
});
});
@@ -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<void> {
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;
@@ -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);
});
+1 -1
View File
@@ -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';
/**
+61 -59
View File
@@ -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<string>();
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);
});
});
+29 -32
View File
@@ -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<TeamRow> {
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<TeamRow> {
/** Authority is one test: the caller is the account named by the team. */
async requireOwner(teamUid: string, actorUserId: number): Promise<TeamRow> {
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<void> {
@@ -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<TeamRow> {
@@ -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<void> {
async deleteTeam(teamUid: string, actorUserId: number): Promise<void> {
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<TeamRow> {
@@ -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);
}
@@ -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, [
+46 -11
View File
@@ -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 });
+32 -21
View File
@@ -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<TeamRow | null> {
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<TeamRow | null> {
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<TeamRow[]> {
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<boolean> {
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<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 ' +
'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<boolean> {
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<TeamRow[]> {
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<boolean> {
// 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<OrgSeatRow | null> {
const rows = (await this.clients.db.read(
'SELECT ug.`id`, ug.`user_id`, u.`uuid`, u.`username`, ' +
+2 -2
View File
@@ -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.
*/