diff --git a/src/backend/clients/database/SqliteDatabaseClient.test.ts b/src/backend/clients/database/SqliteDatabaseClient.test.ts
index c74bffa7d..6bdbfa4ff 100644
--- a/src/backend/clients/database/SqliteDatabaseClient.test.ts
+++ b/src/backend/clients/database/SqliteDatabaseClient.test.ts
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
/** Highest schema version the migration table can reach. */
-const CURRENT_SCHEMA_VERSION = 72;
+const CURRENT_SCHEMA_VERSION = 73;
/**
* These suites migrate real files on disk. Idle they finish in well under a
@@ -81,6 +81,69 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
expect(await userVersionOf(client)).toBe(CURRENT_SCHEMA_VERSION);
});
+ it('applies the team columns and indexes from 0077', async () => {
+ const columnsOf = async (table: string) =>
+ (
+ (await client.read(
+ `SELECT name FROM pragma_table_info('${table}')`,
+ )) as { name: string }[]
+ ).map((r) => r.name);
+
+ expect(await columnsOf('group')).toEqual(
+ expect.arrayContaining([
+ 'kind',
+ 'name',
+ 'handle',
+ 'deleted_at',
+ ]),
+ );
+ expect(await columnsOf('jct_user_group')).toContain('org_owned');
+ expect(await columnsOf('user')).toContain('requires_password_change');
+
+ const indexes = (await client.read(
+ "SELECT name FROM sqlite_master WHERE type = 'index' AND name IN (?, ?, ?)",
+ ['idx_group_handle', 'idx_group_owner', 'idx_jct_user_group_group'],
+ )) as { name: string }[];
+ expect(indexes.map((r) => r.name).sort()).toEqual([
+ 'idx_group_handle',
+ 'idx_group_owner',
+ 'idx_jct_user_group_group',
+ ]);
+ });
+
+ it('keeps `handle` unique but lets the seeded groups share a NULL one', async () => {
+ const seeded = (await client.read(
+ 'SELECT COUNT(*) AS n FROM `group` WHERE `handle` IS NULL',
+ )) as { n: number }[];
+ expect(seeded[0].n).toBeGreaterThan(1);
+
+ await client.write(
+ 'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MIN(`id`) FROM `group`)',
+ ['taken'],
+ );
+ await expect(
+ client.write(
+ 'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MAX(`id`) FROM `group`)',
+ ['taken'],
+ ),
+ ).rejects.toThrow(/UNIQUE/iu);
+ });
+
+ it('treats `handle` case-insensitively, as usernames are treated', async () => {
+ await client.write(
+ 'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MIN(`id`) FROM `group`)',
+ ['design-team'],
+ );
+ // Without COLLATE NOCASE this differs per engine: MySQL rejects it via
+ // utf8mb4_unicode_ci while sqlite and postgres would let it through.
+ await expect(
+ client.write(
+ 'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MAX(`id`) FROM `group`)',
+ ['Design-Team'],
+ ),
+ ).rejects.toThrow(/UNIQUE/iu);
+ });
+
it('runs the javascript migrations, not just the .sql ones', async () => {
// The `system` user only exists because 0025 (a .dbmig.js file) ran
// inside the migration VM.
diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts
index 0c28b2d78..164af0e04 100644
--- a/src/backend/clients/database/SqliteDatabaseClient.ts
+++ b/src/backend/clients/database/SqliteDatabaseClient.ts
@@ -106,6 +106,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[69, ['0074_add_user_home.sql']],
[70, ['0075_event-subscriptions.sql']],
[71, ['0076_event-handlers.sql']],
+ [72, ['0077_teams.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_32.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_32.sql
new file mode 100644
index 000000000..14ee32d66
--- /dev/null
+++ b/src/backend/clients/database/migrations/mysql/mysql_mig_32.sql
@@ -0,0 +1,58 @@
+-- Copyright (C) 2024-present Puter Technologies Inc.
+--
+-- This file is part of Puter.
+--
+-- Puter is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as published
+-- by the Free Software Foundation, either version 3 of the License, or
+-- (at your option) any later version.
+--
+-- This program is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU Affero General Public License for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program. If not, see .
+
+-- Teams. See sqlite/0077_teams.sql for the column rationale.
+-- No per-file applied-state tracking, so every statement must tolerate a re-run:
+-- columns go through _puter_add_col, indexes through the guarded procedure below.
+
+CALL _puter_add_col('group', 'kind', '`kind` varchar(20) DEFAULT NULL');
+CALL _puter_add_col('group', 'name', '`name` varchar(255) DEFAULT NULL');
+CALL _puter_add_col('group', 'handle', '`handle` varchar(64) DEFAULT NULL');
+CALL _puter_add_col('group', 'deleted_at', '`deleted_at` timestamp NULL DEFAULT NULL');
+CALL _puter_add_col('jct_user_group', 'org_owned', '`org_owned` tinyint(1) DEFAULT NULL');
+CALL _puter_add_col('user', 'requires_password_change', '`requires_password_change` tinyint(1) DEFAULT NULL');
+
+DROP PROCEDURE IF EXISTS _puter_add_team_indexes;
+DELIMITER //
+CREATE PROCEDURE _puter_add_team_indexes()
+BEGIN
+ -- Already case-insensitive: the table is utf8mb4_unicode_ci. NULLs stay distinct.
+ IF NOT EXISTS (
+ SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'group'
+ AND INDEX_NAME = 'idx_group_handle'
+ ) THEN
+ ALTER TABLE `group` ADD UNIQUE INDEX `idx_group_handle` (`handle`);
+ END IF;
+
+ -- Composite, unlike the existing single-column `group_id` key.
+ IF NOT EXISTS (
+ SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'jct_user_group'
+ AND INDEX_NAME = 'idx_jct_user_group_group'
+ ) THEN
+ ALTER TABLE `jct_user_group`
+ ADD INDEX `idx_jct_user_group_group` (`group_id`, `user_id`);
+ END IF;
+END//
+DELIMITER ;
+
+CALL _puter_add_team_indexes();
+
+DROP PROCEDURE IF EXISTS _puter_add_team_indexes;
diff --git a/src/backend/clients/database/migrations/postgres/postgres_mig_21.sql b/src/backend/clients/database/migrations/postgres/postgres_mig_21.sql
new file mode 100644
index 000000000..9c31ad539
--- /dev/null
+++ b/src/backend/clients/database/migrations/postgres/postgres_mig_21.sql
@@ -0,0 +1,38 @@
+-- Copyright (C) 2024-present Puter Technologies Inc.
+--
+-- This file is part of Puter.
+--
+-- Puter is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as published
+-- by the Free Software Foundation, either version 3 of the License, or
+-- (at your option) any later version.
+--
+-- This program is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU Affero General Public License for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program. If not, see .
+
+-- Teams. See sqlite/0077_teams.sql for the column rationale.
+-- Idempotent via IF NOT EXISTS; there is no per-file applied-state tracking.
+
+ALTER TABLE "group" ADD COLUMN IF NOT EXISTS kind text;
+ALTER TABLE "group" ADD COLUMN IF NOT EXISTS name text;
+ALTER TABLE "group" ADD COLUMN IF NOT EXISTS handle text;
+ALTER TABLE "group" ADD COLUMN IF NOT EXISTS deleted_at timestamp;
+
+-- On lower(handle): text is case-sensitive here, unlike MySQL's utf8mb4_unicode_ci.
+-- Handle lookups must therefore compare lower(handle) to hit this index.
+CREATE UNIQUE INDEX IF NOT EXISTS idx_group_handle ON "group" (lower(handle));
+
+-- No idx_group_owner here: idx_group_owner_user_id already covers it.
+
+ALTER TABLE jct_user_group ADD COLUMN IF NOT EXISTS org_owned smallint;
+
+-- Composite, unlike the existing single-column idx_jct_user_group_group_id.
+CREATE INDEX IF NOT EXISTS idx_jct_user_group_group
+ ON jct_user_group (group_id, user_id);
+
+ALTER TABLE "user" ADD COLUMN IF NOT EXISTS requires_password_change smallint;
diff --git a/src/backend/clients/database/migrations/sqlite/0077_teams.sql b/src/backend/clients/database/migrations/sqlite/0077_teams.sql
new file mode 100644
index 000000000..5140e27aa
--- /dev/null
+++ b/src/backend/clients/database/migrations/sqlite/0077_teams.sql
@@ -0,0 +1,42 @@
+-- Copyright (C) 2024-present Puter Technologies Inc.
+--
+-- This file is part of Puter.
+--
+-- Puter is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as published
+-- by the Free Software Foundation, either version 3 of the License, or
+-- (at your option) any later version.
+--
+-- This program is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU Affero General Public License for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program. If not, see .
+
+-- Teams: a `group` row with `kind = 'team'` owns accounts and pays for them.
+-- Seeded system groups keep `kind` NULL, so a team query never returns them.
+
+ALTER TABLE `group` ADD COLUMN `kind` TEXT DEFAULT NULL;
+ALTER TABLE `group` ADD COLUMN `name` TEXT DEFAULT NULL;
+ALTER TABLE `group` ADD COLUMN `handle` TEXT DEFAULT NULL;
+ALTER TABLE `group` ADD COLUMN `deleted_at` TIMESTAMP DEFAULT NULL;
+
+-- NOCASE so a handle is unique the way a username is (see 0055). MySQL gets this
+-- from utf8mb4_unicode_ci; postgres needs a lower() index. NULLs stay distinct.
+CREATE UNIQUE INDEX IF NOT EXISTS `idx_group_handle`
+ ON `group` (`handle` COLLATE NOCASE);
+
+-- 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.
+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.
+CREATE INDEX IF NOT EXISTS `idx_jct_user_group_group`
+ ON `jct_user_group` (`group_id`, `user_id`);
+
+-- Fourth `requires_*` flag, enforced by assertVerifiedAccount. Set on admin reset.
+ALTER TABLE `user` ADD COLUMN `requires_password_change` INTEGER DEFAULT NULL;