chore(perms): drop the three by-hand groups no code reads

freeai, experimental and dangerous exist in prod but in no migration — they
were added by hand when hardcoded permissions were keyed by group name. That
map is now a flat per-user floor (`default_user_permissions`), so a group
nothing looks up grants nothing.

Guarded rather than unconditional, because both tables the delete can reach
cascade: dropping a group that still carries permissions or members would
silently revoke them from every member. Only a group with neither goes. One that
survives has dependents and needs a deliberate decision — query
user_to_group_permissions by group_id to see what it holds.

system, admin, user and temp are untouched: config names two of them and code
names the others.

Matches on `extra.name`, not `metadata.name` — `metadata` carries the display
title and colour, and `critical: true` is set on all of these including freeai,
so it does not discriminate.
This commit is contained in:
Juan Castro
2026-08-19 14:01:57 -04:00
parent 3f2767f279
commit 37ed2859a3
5 changed files with 177 additions and 2 deletions
@@ -18,7 +18,7 @@
*/
import Database from 'better-sqlite3';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -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 = 65;
const CURRENT_SCHEMA_VERSION = 66;
/**
* These suites migrate real files on disk. Idle they finish in well under a
@@ -91,6 +91,105 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
expect(rows).toEqual([{ username: 'system' }]);
});
// 0070 sweeps three by-hand groups no code reads. Both tables it can reach
// cascade on delete, so the guard is the point: a group that still carries
// permissions or members has to survive, or the sweep silently revokes
// them. Re-running the statement is safe — it is a plain conditional DELETE.
describe('0070 drop-orphaned-default-groups', () => {
const MIGRATION = readFileSync(
new URL(
'./migrations/sqlite/0070_drop-orphaned-default-groups.sql',
import.meta.url,
),
'utf8',
);
const seedGroup = async (name: string): Promise<number> => {
const [system] = await client.read(
'SELECT `id` FROM `user` WHERE `uuid` = ?',
[SYSTEM_USER_UUID],
);
const uid = `grp-${name}-${Math.random().toString(36).slice(2, 10)}`;
await client.write(
'INSERT INTO `group` (`uid`, `owner_user_id`, `extra`, `metadata`) ' +
'VALUES (?, ?, ?, ?)',
[uid, system.id, JSON.stringify({ name }), '{}'],
);
const [row] = await client.read(
'SELECT `id` FROM `group` WHERE `uid` = ?',
[uid],
);
return Number(row.id);
};
const stillThere = async (id: number): Promise<boolean> => {
const rows = await client.read(
'SELECT `id` FROM `group` WHERE `id` = ?',
[id],
);
return rows.length > 0;
};
it('drops an orphaned freeai/experimental/dangerous group', async () => {
const ids = await Promise.all(
['freeai', 'experimental', 'dangerous'].map(seedGroup),
);
await client.write(MIGRATION);
for (const id of ids) expect(await stillThere(id)).toBe(false);
});
it('spares one that still carries a permission row', async () => {
const id = await seedGroup('freeai');
const [system] = await client.read(
'SELECT `id` FROM `user` WHERE `uuid` = ?',
[SYSTEM_USER_UUID],
);
await client.write(
'INSERT INTO `user_to_group_permissions` ' +
'(`user_id`, `group_id`, `permission`, `extra`) VALUES (?, ?, ?, ?)',
[system.id, id, 'service:some-paid-thing', '{}'],
);
await client.write(MIGRATION);
expect(await stillThere(id)).toBe(true);
// And the grant it carries is still intact, not cascaded away.
const perms = await client.read(
'SELECT `permission` FROM `user_to_group_permissions` WHERE `group_id` = ?',
[id],
);
expect(perms).toEqual([{ permission: 'service:some-paid-thing' }]);
});
it('spares one that still has members', async () => {
const id = await seedGroup('experimental');
const [system] = await client.read(
'SELECT `id` FROM `user` WHERE `uuid` = ?',
[SYSTEM_USER_UUID],
);
await client.write(
'INSERT INTO `jct_user_group` (`user_id`, `group_id`) VALUES (?, ?)',
[system.id, id],
);
await client.write(MIGRATION);
expect(await stillThere(id)).toBe(true);
});
it('leaves the groups the platform actually depends on alone', async () => {
// system, admin, user and temp are named by config or code.
await client.write(MIGRATION);
const rows = await client.read(
"SELECT json_extract(`extra`, '$.name') AS name FROM `group`",
);
const names = rows.map((r) => String(r.name));
for (const name of ['system', 'admin', 'user']) {
expect(names).toContain(name);
}
});
});
it('leaves an already-migrated database untouched on a second boot', async () => {
const dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-'));
const path = join(dir, 'nested', 'puter.sqlite');
@@ -99,6 +99,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[62, ['0067_share_entries.sql']],
[63, ['0068_referral-code-unique.sql']],
[64, ['0069_user-block.sql']],
[65, ['0070_drop-orphaned-default-groups.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
@@ -0,0 +1,22 @@
-- Drops three groups that no code has ever read: `freeai`, `experimental` and
-- `dangerous`. No migration creates them — they were added by hand back when
-- hardcoded permissions were keyed by group name. That map is now a flat
-- per-user floor (`default_user_permissions` in data/hardcoded-permissions.js),
-- so a group nothing looks up grants nothing, and the rows are dead weight.
--
-- Guarded, and deliberately so: `user_to_group_permissions.group_id` and
-- `jct_user_group.group_id` both cascade on delete, so removing a group that
-- still carries permissions or members would silently revoke them from every
-- member. Only a group with neither is dropped. A group that survives this
-- migration has dependents and needs a deliberate decision, not a sweep --
-- query `user_to_group_permissions` by `group_id` to see what it holds.
--
-- The joins rather than `NOT IN` subqueries: MySQL will not read from the table
-- it is deleting from inside a subquery on the same statement.
DELETE g FROM `group` g
LEFT JOIN `user_to_group_permissions` p ON p.`group_id` = g.`id`
LEFT JOIN `jct_user_group` j ON j.`group_id` = g.`id`
WHERE JSON_UNQUOTE(JSON_EXTRACT(g.`extra`, '$.name'))
IN ('freeai', 'experimental', 'dangerous')
AND p.`group_id` IS NULL
AND j.`group_id` IS NULL;
@@ -0,0 +1,20 @@
-- Drops three groups that no code has ever read: `freeai`, `experimental` and
-- `dangerous`. No migration creates them — they were added by hand back when
-- hardcoded permissions were keyed by group name. That map is now a flat
-- per-user floor (`default_user_permissions` in data/hardcoded-permissions.js),
-- so a group nothing looks up grants nothing, and the rows are dead weight.
--
-- Guarded, and deliberately so: user_to_group_permissions.group_id and
-- jct_user_group.group_id both cascade on delete, so removing a group that
-- still carries permissions or members would silently revoke them from every
-- member. Only a group with neither is dropped. A group that survives this
-- migration has dependents and needs a deliberate decision, not a sweep --
-- query user_to_group_permissions by group_id to see what it holds.
DELETE FROM "group" g
WHERE g.extra ->> 'name' IN ('freeai', 'experimental', 'dangerous')
AND NOT EXISTS (
SELECT 1 FROM user_to_group_permissions p WHERE p.group_id = g.id
)
AND NOT EXISTS (
SELECT 1 FROM jct_user_group j WHERE j.group_id = g.id
);
@@ -0,0 +1,33 @@
-- 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 <https://www.gnu.org/licenses/>.
-- Drops three groups that no code has ever read: `freeai`, `experimental` and
-- `dangerous`. No migration creates them — they were added by hand back when
-- hardcoded permissions were keyed by group name. That map is now a flat
-- per-user floor (`default_user_permissions` in data/hardcoded-permissions.js),
-- so a group nothing looks up grants nothing, and the rows are dead weight.
--
-- Guarded, and deliberately so: `user_to_group_permissions.group_id` and
-- `jct_user_group.group_id` both cascade on delete, so removing a group that
-- still carries permissions or members would silently revoke them from every
-- member. Only a group with neither is dropped. A group that survives this
-- migration has dependents and needs a deliberate decision, not a sweep --
-- query `user_to_group_permissions` by `group_id` to see what it holds.
DELETE FROM `group`
WHERE json_extract(`extra`, '$.name') IN ('freeai', 'experimental', 'dangerous')
AND `id` NOT IN (SELECT `group_id` FROM `user_to_group_permissions`)
AND `id` NOT IN (SELECT `group_id` FROM `jct_user_group`);