PUT-1868 (#3940)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

This commit is contained in:
Neal Shah
2026-09-24 00:30:47 -04:00
committed by GitHub
parent 7758aea78c
commit 07a11240ea
12 changed files with 453 additions and 2 deletions
@@ -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 = 81;
const CURRENT_SCHEMA_VERSION = 82;
/**
* These suites migrate real files on disk. Idle they finish in well under a
@@ -276,6 +276,68 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
]);
});
it('deletes the subdomain rows an app owns when the app is deleted', async () => {
const [user] = (await client.read(
'SELECT MIN(`id`) AS id FROM `user`',
)) as { id: number }[];
await client.write(
'INSERT INTO `apps` (`uid`, `owner_user_id`, `name`, `title`, `index_url`) ' +
'VALUES (?, ?, ?, ?, ?)',
[
'app-cascade-test',
user.id,
'cascade-test',
'cascade-test',
'https://cascade.test/',
],
);
const [app] = (await client.read(
'SELECT `id` FROM `apps` WHERE `uid` = ?',
['app-cascade-test'],
)) as { id: number }[];
const insertSubdomain = (
uuid: string,
name: string,
appOwner: number | null,
) =>
client.write(
'INSERT INTO `subdomains` (`uuid`, `subdomain`, `user_id`, `app_owner`) ' +
'VALUES (?, ?, ?, ?)',
[uuid, name, user.id, appOwner],
);
await insertSubdomain('sd-cascade-owned', 'cascade-owned', app.id);
await insertSubdomain('sd-cascade-unowned', 'cascade-unowned', null);
await client.write('DELETE FROM `apps` WHERE `id` = ?', [app.id]);
await expect(
client.read(
'SELECT `subdomain` FROM `subdomains` WHERE `subdomain` LIKE ? ORDER BY `id`',
['cascade-%'],
),
).resolves.toEqual([{ subdomain: 'cascade-unowned' }]);
});
it('keeps every subdomains column through the 0086 rebuild', async () => {
const columns = (await client.read(
"SELECT `name` FROM pragma_table_info('subdomains') ORDER BY `cid`",
)) as { name: string }[];
expect(columns.map((c) => c.name)).toEqual([
'id',
'uuid',
'subdomain',
'user_id',
'root_dir_id',
'associated_app_id',
'ts',
'app_owner',
'protected',
'domain',
'database_id',
'preamble_version',
]);
});
it('constrains team shares that the user-holder index cannot', async () => {
const [user] = (await client.read(
'SELECT MIN(`id`) AS id FROM `user`',
@@ -115,6 +115,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[78, ['0083_team-directory.sql']],
[79, ['0084_share-anyone-with-link.sql']],
[80, ['0085_event-subscriptions-include-value.sql']],
[81, ['0086_subdomains-app-owner-cascade.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
@@ -0,0 +1,75 @@
-- 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/>.
-- Mirrors SQLite migration 0086: `subdomains.app_owner` cascades on app
-- delete instead of nulling. The existing constraint is found by column
-- rather than by name, since an older database need not carry the name
-- mysql_mig_1 declares. Guarded procedure as mysql_mig_34: a replay sees
-- DELETE_RULE already CASCADE and does nothing.
DROP PROCEDURE IF EXISTS _puter_subdomains_app_owner_cascade;
DELIMITER //
CREATE PROCEDURE _puter_subdomains_app_owner_cascade()
BEGIN
DECLARE fk_name VARCHAR(64) DEFAULT NULL;
-- A subquery, not SELECT ... INTO: no matching row yields NULL quietly.
SET fk_name = (
SELECT rc.CONSTRAINT_NAME
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
ON kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA
AND kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME
AND kcu.TABLE_NAME = rc.TABLE_NAME
WHERE rc.CONSTRAINT_SCHEMA = DATABASE()
AND rc.TABLE_NAME = 'subdomains'
AND rc.REFERENCED_TABLE_NAME = 'apps'
AND kcu.COLUMN_NAME = 'app_owner'
AND rc.DELETE_RULE <> 'CASCADE'
LIMIT 1
);
IF fk_name IS NOT NULL THEN
SET @s := CONCAT('ALTER TABLE `subdomains` DROP FOREIGN KEY `', fk_name, '`');
PREPARE stmt FROM @s;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
IF NOT EXISTS (
SELECT 1
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
ON kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA
AND kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME
AND kcu.TABLE_NAME = rc.TABLE_NAME
WHERE rc.CONSTRAINT_SCHEMA = DATABASE()
AND rc.TABLE_NAME = 'subdomains'
AND rc.REFERENCED_TABLE_NAME = 'apps'
AND kcu.COLUMN_NAME = 'app_owner'
AND rc.DELETE_RULE = 'CASCADE'
) THEN
ALTER TABLE `subdomains` ADD CONSTRAINT `fk_subdomains_app_owner`
FOREIGN KEY (`app_owner`) REFERENCES `apps` (`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END//
DELIMITER ;
CALL _puter_subdomains_app_owner_cascade();
DROP PROCEDURE IF EXISTS _puter_subdomains_app_owner_cascade;
@@ -0,0 +1,59 @@
-- 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/>.
-- Mirrors SQLite migration 0086: `subdomains.app_owner` cascades on app
-- delete instead of nulling. The existing constraint is found by column, not
-- by name. Idempotent: there is no per-file applied-state tracking, and a
-- replay sees the cascade already in place and does nothing.
DO $$
DECLARE
fk_name text;
BEGIN
SELECT c.conname INTO fk_name
FROM pg_constraint c
JOIN pg_attribute a
ON a.attrelid = c.conrelid
AND a.attnum = ANY (c.conkey)
WHERE c.conrelid = 'subdomains'::regclass
AND c.contype = 'f'
AND c.confrelid = 'apps'::regclass
AND a.attname = 'app_owner'
AND c.confdeltype <> 'c'
LIMIT 1;
IF fk_name IS NOT NULL THEN
EXECUTE format('ALTER TABLE subdomains DROP CONSTRAINT %I', fk_name);
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_attribute a
ON a.attrelid = c.conrelid
AND a.attnum = ANY (c.conkey)
WHERE c.conrelid = 'subdomains'::regclass
AND c.contype = 'f'
AND c.confrelid = 'apps'::regclass
AND a.attname = 'app_owner'
AND c.confdeltype = 'c'
) THEN
ALTER TABLE subdomains ADD CONSTRAINT subdomains_app_owner_fkey
FOREIGN KEY (app_owner) REFERENCES apps (id)
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
@@ -0,0 +1,52 @@
-- 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/>.
-- A deleted app takes the subdomain rows it owns (its hosted sites and its
-- workers) with it. `SET NULL` left them behind as unowned rows, and an
-- unowned worker row is redeployed with the account's own credential.
-- SQLite cannot alter a constraint, so the table is rebuilt as 0043 does.
PRAGMA foreign_keys = OFF;
CREATE TABLE `subdomains_new` (
`id` INTEGER PRIMARY KEY,
`uuid` varchar(40) DEFAULT NULL,
`subdomain` varchar(64) NOT NULL,
`user_id` int(10) NOT NULL,
`root_dir_id` int(10) DEFAULT NULL,
`associated_app_id` int(10) DEFAULT NULL,
`ts` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`app_owner` int(10) DEFAULT NULL,
`protected` tinyint(1) DEFAULT '0',
`domain` varchar(256) DEFAULT NULL,
`database_id` varchar(40) DEFAULT NULL,
`preamble_version` varchar(64) DEFAULT NULL,
FOREIGN KEY (`app_owner`) REFERENCES `apps` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO `subdomains_new`
(`id`, `uuid`, `subdomain`, `user_id`, `root_dir_id`, `associated_app_id`, `ts`,
`app_owner`, `protected`, `domain`, `database_id`, `preamble_version`)
SELECT
`id`, `uuid`, `subdomain`, `user_id`, `root_dir_id`, `associated_app_id`, `ts`,
`app_owner`, `protected`, `domain`, `database_id`, `preamble_version`
FROM `subdomains`;
DROP TABLE `subdomains`;
ALTER TABLE `subdomains_new` RENAME TO `subdomains`;
PRAGMA foreign_keys = ON;
+6
View File
@@ -1451,6 +1451,12 @@ export class AppDriver extends PuterDriver {
});
const sourceApp = await this.appStore.getByUid(sourceAppUid);
if (sourceApp) {
// The source app's sites and workers follow it into the
// joined row; `app_owner` cascades on delete otherwise.
await this.stores.subdomain.reassignAppOwner(
sourceApp.id,
appToJoin.id,
);
await this.appStore.delete(sourceApp.id);
this.#emitAppChanged({
app: null,
+81 -1
View File
@@ -515,6 +515,40 @@ describe('AppDriver.delete', () => {
).toBeNull();
});
it('deletes the subdomain rows the app owns and leaves the rest', async () => {
const { actor, userId } = await makeUser();
const created = await withActor(actor, () =>
driver.create({
object: {
name: uniqueName('own'),
title: 't',
index_url: uniqueIndexUrl(),
},
}),
);
const app = (await server.stores.app.getByUid(created.uid as string))!;
const prefix = `appdel-${Math.random().toString(36).slice(2, 8)}`;
await server.stores.subdomain.create({
userId,
subdomain: `${prefix}-owned`,
appOwner: app.id,
});
await server.stores.subdomain.create({
userId,
subdomain: `${prefix}-unowned`,
});
await withActor(actor, () => driver.delete({ uid: created.uid }));
const remaining = await server.stores.subdomain.listByUserIdAndPrefix(
userId,
prefix,
);
expect(remaining.map((r) => r.subdomain)).toEqual([
`${prefix}-unowned`,
]);
});
it("refuses to delete another user's app with 403", async () => {
const a = await makeUser();
const b = await makeUser();
@@ -1319,6 +1353,7 @@ describe('AppDriver.isNameAvailable additional branches', () => {
describe('AppDriver alias-group index_url merge', () => {
const aliasHostA = `alias-a-${Math.random().toString(36).slice(2, 10)}.test`;
const aliasHostB = `alias-b-${Math.random().toString(36).slice(2, 10)}.test`;
const aliasHostC = `alias-c-${Math.random().toString(36).slice(2, 10)}.test`;
// `config` is protected on PuterDriver; reach in to toggle the alias
// groups for this block only. `#getOriginAliasGroups` reads config at
@@ -1327,7 +1362,11 @@ describe('AppDriver alias-group index_url merge', () => {
(driver as unknown as { config: Record<string, unknown> }).config;
beforeAll(() => {
driverConfig().app_origin_aliases = [[aliasHostA], [aliasHostB]];
driverConfig().app_origin_aliases = [
[aliasHostA],
[aliasHostB],
[aliasHostC],
];
});
afterAll(() => {
@@ -1413,6 +1452,47 @@ describe('AppDriver alias-group index_url merge', () => {
expect(viaOldUid.uid).toBe(stubUid);
});
it("update merge hands the source app's owned subdomain rows to the joined app", async () => {
const { actor, userId } = await makeUser();
const stubUid = await makeBootstrapStub(aliasHostC);
const created = await withActor(actor, () =>
driver.create({
object: {
name: uniqueName('alias-own'),
title: 't',
index_url: uniqueIndexUrl(),
},
}),
);
const sourceApp = (await server.stores.app.getByUid(
created.uid as string,
))!;
const subdomain = `merge-${Math.random().toString(36).slice(2, 8)}`;
await server.stores.subdomain.create({
userId,
subdomain,
appOwner: sourceApp.id,
});
const updated = await withActor(actor, () =>
driver.update({
uid: created.uid,
object: { index_url: `https://${aliasHostC}/` },
}),
);
expect(updated.uid).toBe(stubUid);
// The source row is gone; its subdomain row survives under the joined
// app rather than cascading away with the source.
const stub = (await server.stores.app.getByUid(stubUid))!;
const [row] = await server.stores.subdomain.listByUserIdAndPrefix(
userId,
subdomain,
);
expect(row).toBeTruthy();
expect(Number(row!.app_owner)).toBe(stub.id);
});
it('leaves unrelated custom domains untouched (no alias group, no conflict check)', async () => {
const a = await makeUser();
const b = await makeUser();
@@ -748,4 +748,60 @@ describe('WorkerDriver hot reload', () => {
await new Promise((r) => setTimeout(r, 120));
expect(fetchSpy).not.toHaveBeenCalled();
});
// Deleting the app a worker is bound to deletes the worker row with it.
// Were the row to survive unbound, the next source write would redeploy
// it with an account-scoped token — a child app is enough to set that up.
it('does not redeploy a worker whose app was deleted, and never falls back to an account-scoped token', async () => {
const { user, actor } = await makeUser();
const builder = await server.stores.app.create(
{
name: `builder-${user.username}`,
title: 'builder',
index_url: `https://builder-${user.username}.example.com/`,
},
{ ownerUserId: user.id },
);
const child = await server.stores.app.create(
{
name: `child-${user.username}`,
title: 'child',
index_url: `https://child-${user.username}.example.com/`,
},
{ ownerUserId: user.id, appOwner: builder.id },
);
const path = `/${user.username}/bound.js`;
await writeSource(actor, user.id, path, 'v1');
const name = `bound-${user.username}`;
await inCtx(actor, () =>
target.create({
appId: child.uid,
workerName: name,
filePath: path,
}),
);
// Uncached listing: the by-name cache would still serve the row.
const rowsFor = () =>
server.stores.subdomain.listByUserIdAndPrefix(
user.id,
`workers.puter.${name}`,
);
const [row] = await rowsFor();
expect(Number(row!.app_owner)).toBe(child.id);
await server.stores.app.delete(child.id);
expect(await rowsFor()).toEqual([]);
fetchSpy.mockClear();
const sessionMint = vi.spyOn(
server.services.auth,
'createWorkerSessionToken',
);
await writeSource(actor, user.id, path, 'v2');
await new Promise((r) => setTimeout(r, 120));
expect(putCalls()).toHaveLength(0);
expect(sessionMint).not.toHaveBeenCalled();
});
});
@@ -1130,6 +1130,10 @@ export class WorkerDriver extends PuterDriver {
// (user, app_uid, worker_name) so a hot-reload reuses
// the same row across reloads and the long-lived token
// stays stable for the worker's whole lifetime.
//
// A null `app_owner` means the worker was never bound to
// an app: a deleted app takes its rows with it, so this
// row cannot be a binding that was lost.
const appOwnerId = row.app_owner as number | null;
let authorization: string;
if (appOwnerId) {
@@ -227,6 +227,8 @@ export class LocalWorkerService extends PuterService {
}
}
async reconstructDeployArgs(workerName: string, row: SubdomainRow) {
// A null `app_owner` means the worker was never bound to an app: a
// deleted app takes its rows with it.
const appOwnerId = row.app_owner as number | null;
let authorization: string;
const ownerUser = await this.stores.user.getById(row.user_id);
@@ -158,6 +158,29 @@ describe('SubdomainStore app_owner filtering', () => {
return subdomain;
};
it('reassignAppOwner moves only the source app rows and refreshes their cache', async () => {
const userId = await makeUser();
const prefix = `sds-move-${Math.random().toString(36).slice(2, 6)}.`;
const from = await makeApp(userId);
const to = await makeApp(userId);
const other = await makeApp(userId);
const moved = await seed(userId, prefix, from.id);
const kept = await seed(userId, prefix, other.id);
const rows = await store.reassignAppOwner(from.id, to.id);
expect(rows.map((r) => r.subdomain)).toEqual([moved]);
// The by-name read must not serve the pre-move owner from cache.
expect(Number((await store.getBySubdomain(moved))!.app_owner)).toBe(
to.id,
);
expect(Number((await store.getBySubdomain(kept))!.app_owner)).toBe(
other.id,
);
expect((await store.listAll({ appOwner: from.id })).length).toBe(0);
});
it('matches rows owned by any app in `appIds`', async () => {
const userId = await makeUser();
const prefix = `sds-multi-${Math.random().toString(36).slice(2, 6)}.`;
@@ -593,6 +593,37 @@ export class SubdomainStore extends PuterStore {
}
}
/**
* Hand every row `fromAppId` owns to `toAppId`. For merging one app row
* into another: `app_owner` cascades on app delete, so the rows have to
* change hands before the source app row goes. Returns the moved rows.
*/
async reassignAppOwner(
fromAppId: number,
toAppId: number,
): Promise<SubdomainRow[]> {
const rows = (await this.listAll({
appOwner: fromAppId,
})) as unknown as SubdomainRow[];
if (rows.length === 0) return rows;
await this.clients.db.write(
'UPDATE `subdomains` SET `app_owner` = ? WHERE `app_owner` = ?',
[toAppId, fromAppId],
);
const userIds = new Set<number>();
for (const row of rows) {
row.app_owner = toAppId;
await this.#refreshCache(row);
if (row.user_id != null) userIds.add(Number(row.user_id));
}
for (const userId of userIds) {
await this.#invalidatePrefixListsForUser(userId);
}
return rows;
}
// -- Internals ----------------------------------------------------
#cacheKey(subdomain: string) {