mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-14 18:01:21 +00:00
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:
1 parent
84368f8d02
commit
00afea1285
15 files changed
+246
-182
No files matched your search
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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, [
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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`, ' +
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user