diff --git a/src/backend/clients/database/SqliteDatabaseClient.test.ts b/src/backend/clients/database/SqliteDatabaseClient.test.ts
index 647b468c0..afac01a81 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 = 73;
+const CURRENT_SCHEMA_VERSION = 74;
/**
* These suites migrate real files on disk. Idle they finish in well under a
@@ -157,6 +157,24 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
).resolves.toEqual([{ handle: 'design-team' }]);
});
+ it('rejects a duplicate membership pair after 0078', async () => {
+ const [{ user_id, group_id }] = (await client.read(
+ 'SELECT (SELECT MIN(`id`) FROM `user`) AS user_id, ' +
+ '(SELECT MIN(`id`) FROM `group`) AS group_id',
+ )) as { user_id: number; group_id: number }[];
+
+ const insert = () =>
+ client.write(
+ 'INSERT INTO `jct_user_group` (`user_id`, `group_id`) VALUES (?, ?)',
+ [user_id, group_id],
+ );
+
+ await insert();
+ // `GroupStore.addUsers` has no conflict clause, so before 0075 this
+ // second call silently doubled every group permission the user reads.
+ await expect(insert()).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.
@@ -483,6 +501,66 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
}
});
+ it('deduplicates pre-existing membership rows when 0078 applies', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-dedup-'));
+ const path = join(dir, 'puter.sqlite');
+ try {
+ // Stop at 67 so the duplicates exist the way a live database's do:
+ // written before the unique index, not after it.
+ const before = new SqliteDatabaseClient(
+ sqliteConfig({ inMemory: false, path, targetVersion: 67 }),
+ );
+ await before.onServerStart();
+
+ const [{ user_id, group_id }] = (await before.read(
+ 'SELECT (SELECT MIN(`id`) FROM `user`) AS user_id, ' +
+ '(SELECT MIN(`id`) FROM `group`) AS group_id',
+ )) as { user_id: number; group_id: number }[];
+
+ for (const pair of [
+ [user_id, group_id],
+ [user_id, group_id],
+ [user_id, group_id],
+ [user_id, group_id + 1],
+ ]) {
+ await before.write(
+ 'INSERT INTO `jct_user_group` (`user_id`, `group_id`) VALUES (?, ?)',
+ pair,
+ );
+ }
+ const seeded = (await before.read(
+ 'SELECT `id`, `group_id` FROM `jct_user_group` ORDER BY `id`',
+ )) as { id: number; group_id: number }[];
+ expect(seeded).toHaveLength(4);
+ before.onServerShutdown();
+
+ const after = new SqliteDatabaseClient(
+ sqliteConfig({ inMemory: false, path }),
+ );
+ await after.onServerStart();
+ expect(await userVersionOf(after)).toBe(CURRENT_SCHEMA_VERSION);
+
+ // Three copies collapse to the lowest id; the distinct pair is left
+ // alone. Losing the higher ids costs nothing -- `addUsers` writes
+ // only the two id columns, so `extra` and `metadata` are NULL.
+ await expect(
+ after.read(
+ 'SELECT `id`, `group_id` FROM `jct_user_group` ORDER BY `id`',
+ ),
+ ).resolves.toEqual([seeded[0], seeded[3]]);
+
+ await expect(
+ after.write(
+ 'INSERT INTO `jct_user_group` (`user_id`, `group_id`) VALUES (?, ?)',
+ [user_id, group_id],
+ ),
+ ).rejects.toThrow(/UNIQUE/iu);
+ after.onServerShutdown();
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
it('stops early at a configured target version', async () => {
const partial = await bootClient({ targetVersion: 5 });
try {
diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts
index 164af0e04..338f78edd 100644
--- a/src/backend/clients/database/SqliteDatabaseClient.ts
+++ b/src/backend/clients/database/SqliteDatabaseClient.ts
@@ -107,6 +107,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[70, ['0075_event-subscriptions.sql']],
[71, ['0076_event-handlers.sql']],
[72, ['0077_teams.sql']],
+ [73, ['0078_jct-user-group-pair-unique.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_33.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_33.sql
new file mode 100644
index 000000000..caa92900f
--- /dev/null
+++ b/src/backend/clients/database/migrations/mysql/mysql_mig_33.sql
@@ -0,0 +1,49 @@
+-- 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 .
+
+-- Deduplicate then constrain. See sqlite/0072 for why duplicates exist and why
+-- dropping the higher-id row is lossless.
+
+-- Self-join rather than `id NOT IN (SELECT MIN(id) ... FROM jct_user_group)`,
+-- which mysql refuses with error 1093 -- it will not read the table it deletes
+-- from in a subquery. Keeps the lowest id of each pair.
+DELETE dup FROM `jct_user_group` dup
+JOIN `jct_user_group` keep
+ ON dup.`user_id` = keep.`user_id`
+ AND dup.`group_id` = keep.`group_id`
+ AND dup.`id` > keep.`id`;
+
+-- Guarded because mysql tracks no applied-state per file, so this may re-run.
+DROP PROCEDURE IF EXISTS _puter_add_membership_pair_index;
+
+DELIMITER //
+CREATE PROCEDURE _puter_add_membership_pair_index()
+BEGIN
+ 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_pair'
+ ) THEN
+ ALTER TABLE `jct_user_group`
+ ADD UNIQUE INDEX `idx_jct_user_group_pair` (`user_id`, `group_id`);
+ END IF;
+END//
+DELIMITER ;
+
+CALL _puter_add_membership_pair_index();
+DROP PROCEDURE IF EXISTS _puter_add_membership_pair_index;
diff --git a/src/backend/clients/database/migrations/postgres/postgres_mig_22.sql b/src/backend/clients/database/migrations/postgres/postgres_mig_22.sql
new file mode 100644
index 000000000..58a9a51a9
--- /dev/null
+++ b/src/backend/clients/database/migrations/postgres/postgres_mig_22.sql
@@ -0,0 +1,28 @@
+-- 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 .
+
+-- Deduplicate then constrain. See sqlite/0072 for why duplicates exist and why
+-- dropping the higher-id row is lossless.
+-- Idempotent: the delete is a no-op once unique, and the index guards itself.
+
+DELETE FROM jct_user_group
+WHERE id NOT IN (
+ SELECT MIN(id) FROM jct_user_group GROUP BY user_id, group_id
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_jct_user_group_pair
+ ON jct_user_group (user_id, group_id);
diff --git a/src/backend/clients/database/migrations/sqlite/0078_jct-user-group-pair-unique.sql b/src/backend/clients/database/migrations/sqlite/0078_jct-user-group-pair-unique.sql
new file mode 100644
index 000000000..baf09da1f
--- /dev/null
+++ b/src/backend/clients/database/migrations/sqlite/0078_jct-user-group-pair-unique.sql
@@ -0,0 +1,36 @@
+-- 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 .
+
+-- `GroupStore.addUsers` is an INSERT ... SELECT with no conflict clause, so a
+-- repeated call inserts a second row for the same (user_id, group_id).
+--
+-- Each duplicate multiplies every row `readUserGroupPerms` returns, because it
+-- joins this table on group_id alone -- two membership rows means every group
+-- permission is reported twice. The index below stops that recurring; the
+-- delete clears what already accumulated.
+--
+-- Dropping the higher-id row discards nothing: `addUsers` writes only the two
+-- id columns, so `extra` and `metadata` are NULL on every row here, nothing has
+-- a foreign key to `jct_user_group.id`, and no code reads it.
+
+DELETE FROM `jct_user_group`
+WHERE `id` NOT IN (
+ SELECT MIN(`id`) FROM `jct_user_group` GROUP BY `user_id`, `group_id`
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS `idx_jct_user_group_pair`
+ ON `jct_user_group` (`user_id`, `group_id`);
diff --git a/src/backend/stores/group/GroupStore.test.ts b/src/backend/stores/group/GroupStore.test.ts
index 4fe04645f..e6b1e6ce4 100644
--- a/src/backend/stores/group/GroupStore.test.ts
+++ b/src/backend/stores/group/GroupStore.test.ts
@@ -92,6 +92,37 @@ describe('GroupStore', () => {
expect(await memberUsernames(uid)).toEqual([member.username]);
});
+ it('treats re-adding an existing member as a no-op, not a conflict', async () => {
+ const uid = await seedGroup(owner.id);
+
+ await store.addUsers(uid, [member.username]);
+ // Before the unique index this duplicated the row; now it would raise.
+ await expect(
+ store.addUsers(uid, [member.username]),
+ ).resolves.toBeUndefined();
+
+ expect(await memberUsernames(uid)).toEqual([member.username]);
+ const rows = await server.clients.db.read(
+ 'SELECT COUNT(*) AS n FROM `jct_user_group` ' +
+ 'WHERE `group_id` = (SELECT id FROM `group` WHERE uid = ?)',
+ [uid],
+ );
+ expect(Number(rows[0].n)).toBe(1);
+ });
+
+ it('adds a new member alongside one that is already present', async () => {
+ const uid = await seedGroup(owner.id);
+ const second = await makeUser();
+
+ await store.addUsers(uid, [member.username]);
+ // The conflicting row must not discard the batch's other inserts.
+ await store.addUsers(uid, [member.username, second.username]);
+
+ expect((await memberUsernames(uid)).sort()).toEqual(
+ [member.username, second.username].sort(),
+ );
+ });
+
it('ignores usernames that do not resolve to a user', async () => {
const uid = await seedGroup(owner.id);
await store.addUsers(uid, ['ghost-user-does-not-exist']);
diff --git a/src/backend/stores/group/GroupStore.ts b/src/backend/stores/group/GroupStore.ts
index de95fed43..5a3d1e36e 100644
--- a/src/backend/stores/group/GroupStore.ts
+++ b/src/backend/stores/group/GroupStore.ts
@@ -35,16 +35,23 @@ import { PuterStore } from '../types';
export class GroupStore extends PuterStore {
/**
* Adds users (by username) to the group identified by `uid`. No-op if
- * `usernames` is empty.
+ * `usernames` is empty, and for a user who is already a member.
*/
async addUsers(uid: string, usernames: string[]): Promise {
if (usernames.length === 0) return;
const placeholders = `(${usernames.map(() => '?').join(', ')})`;
+ // Ignore conflicts on the (user_id, group_id) unique index added in
+ // 0072. Re-adding a member was previously a silent duplicate row, which
+ // is what that index exists to stop -- without this it becomes a raised
+ // error instead, and every caller here treats a throw as a failed
+ // signup step worth warning about.
await this.clients.db.write(
- 'INSERT INTO `jct_user_group` (`user_id`, `group_id`) ' +
+ `${this.clients.db.insertIgnoreInto('jct_user_group')} ` +
+ '(`user_id`, `group_id`) ' +
'SELECT u.id, g.id FROM `user` u ' +
'JOIN (SELECT id FROM `group` WHERE uid = ?) g ON 1 = 1 ' +
- `WHERE u.username IN ${placeholders}`,
+ `WHERE u.username IN ${placeholders}` +
+ this.clients.db.insertIgnoreSuffix(),
[uid, ...usernames],
);
}