Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config.template.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`);

Expand Down
44 changes: 39 additions & 5 deletions src/backend/clients/event/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,24 @@ type GuiEvent<R = Record<string, unknown>> = {
// The entry arrives under several aliases, for handlers of any vintage.
type FsCreateEvent = { node: FSEntry; entry: FSEntry; uid: string };

/**
* Who pays, and for which team. Deliberately no payment identity: the
* payer's row is alive at emit time, so a consumer resolves it when it acts --
* a snapshot taken here would be stale, and two events racing on it would each
* create their own customer.
*/
export type TeamBillingContext = {
team_uid: string;
owner_user_id: number;
};

/** A team event about one seat. */
export type TeamBillingEvent = TeamBillingContext & {
user_id: number;
user_uuid: string;
username: string;
};

/**
* Extension-augmentable half of {@link EventMap}. Extensions that emit their own
* events declare the payload here by declaration merging, so both the emitter
Expand Down Expand Up @@ -334,6 +352,23 @@ export type EventMap = {
stripe_customer_id?: string | null;
};

// ---- Team billing ---- the trigger, never the charge ----
'team.account.created': TeamBillingEvent;
/** `held_bytes` is what to bill storage on while the seat is off. */
'team.account.disabled': TeamBillingEvent & { held_bytes: number };
'team.account.enabled': TeamBillingEvent & { held_bytes: number };
/** Emitted pre-delete: the membership row cascades away with the user. */
'team.account.deleted': TeamBillingEvent;
/** Per-seat charges stop; byte charges do not, the accounts remain. */
'team.deleted': TeamBillingContext & { account_count: number };
/** A budget line was crossed. Emitted on transition only, never per request. */
'metering.credit-state': {
user_uuid: string;
state: 'near-limit' | 'exhausted';
allowance_used: number;
month_usage_allowance: number;
};

// ---- Filesystem ----
'fs.copy.node': {
source: unknown;
Expand Down Expand Up @@ -748,11 +783,10 @@ export type EventKey = keyof EventMap & string;
// Generates a wildcard for every non-final dot-separated prefix of K.
export type WildcardPrefixes<K extends string> =
K extends `${infer Head}.${infer Tail}`
?
| `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
? | `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
: never;

export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
Expand Down
4 changes: 2 additions & 2 deletions src/backend/controllers/auth/AuthController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2087,7 +2087,7 @@ describe('AuthController grant flows', () => {
makeReq(
{
app_uid: app.uid,
permission: `${namespace}:workspace:abc`,
permission: `${namespace}:team:abc`,
},
{ actor: issuerActor },
),
Expand Down Expand Up @@ -5298,7 +5298,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(
Expand Down
2 changes: 1 addition & 1 deletion src/backend/controllers/auth/AuthController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down
57 changes: 30 additions & 27 deletions src/backend/controllers/team/TeamController.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
Expand All @@ -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}`],
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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(
Expand All @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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,
);
Expand Down Expand Up @@ -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',
);
}
});
Expand All @@ -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();
});
});
10 changes: 5 additions & 5 deletions src/backend/controllers/team/TeamController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
);
Expand Down Expand Up @@ -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',
});
}
Expand All @@ -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;
Expand Down
Loading
Loading