diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts index 7bf97d9ca2..03604a1172 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.ts @@ -82,6 +82,9 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [ [47, ['0051_sessions_v2.sql']], [48, ['0052_sessions_v2_lookups.sql']], [49, ['0053_sessions_access_token_uid.sql']], + [50, ['0054_sessions_workers.sql']], + [50, ['0055_username_nocase_unique.sql']], + [51, ['0056_sessions_kind_worker.sql']], ]; export class SqliteDatabaseClient extends AbstractDatabaseClient { diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_12.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_12.sql new file mode 100644 index 0000000000..bfee46aa4e --- /dev/null +++ b/src/backend/clients/database/migrations/mysql/mysql_mig_12.sql @@ -0,0 +1,50 @@ +-- 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 . + +-- Add 'worker' to the `sessions.kind` ENUM. mig_11 already added the +-- worker uniqueness index and the application code in SessionStore +-- inserts rows with kind='worker', but the ENUM defined in mig_8 never +-- listed 'worker' as a permitted value. Under STRICT_TRANS_TABLES the +-- INSERT fails outright; under a relaxed sql_mode the value is coerced +-- to '' (and the worker SELECT-by-kind path then misses the row). +-- Mirrors SQLite migration 0056. +-- +-- Idempotent: guarded against COLUMN_TYPE so re-running the directory is +-- a no-op once 'worker' is in the ENUM. + +DROP PROCEDURE IF EXISTS _puter_sessions_kind_worker; +DELIMITER // +CREATE PROCEDURE _puter_sessions_kind_worker() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sessions' + AND COLUMN_NAME = 'kind' + AND FIND_IN_SET('worker', REPLACE(REPLACE(REPLACE(COLUMN_TYPE, 'enum(', ''), ')', ''), '''', '')) > 0 + ) THEN + ALTER TABLE `sessions` + MODIFY COLUMN `kind` + ENUM('web', 'app', 'access_token', 'asset', 'worker') + NOT NULL DEFAULT 'web'; + END IF; +END// +DELIMITER ; + +CALL _puter_sessions_kind_worker(); + +DROP PROCEDURE IF EXISTS _puter_sessions_kind_worker; diff --git a/src/backend/clients/database/migrations/sqlite/0056_sessions_kind_worker.sql b/src/backend/clients/database/migrations/sqlite/0056_sessions_kind_worker.sql new file mode 100644 index 0000000000..5d14e89f89 --- /dev/null +++ b/src/backend/clients/database/migrations/sqlite/0056_sessions_kind_worker.sql @@ -0,0 +1,90 @@ +-- 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 . + +-- Add 'worker' to the `kind` CHECK constraint on `sessions`. Mirrors the +-- MySQL ENUM extension in mysql_mig_12. +-- +-- SQLite cannot ALTER a CHECK constraint in place, so we follow the +-- standard 12-step rebuild: create `sessions_new` with the corrected +-- constraint, copy rows, drop the old table, rename, then recreate every +-- index that lived on the original (indexes are auto-dropped with their +-- table). + +CREATE TABLE `sessions_new` ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "user_id" INTEGER NOT NULL, + "uuid" TEXT NOT NULL, + "meta" JSON DEFAULT NULL, + "created_at" INTEGER DEFAULT 0, + "last_activity" INTEGER DEFAULT 0, + "kind" TEXT NOT NULL DEFAULT 'web' + CHECK (`kind` IN ('web', 'app', 'access_token', 'asset', 'worker')), + "label" TEXT, + "parent_session_id" TEXT, + "last_ip" TEXT, + "last_user_agent" TEXT, + "revoked_at" INTEGER, + "expires_at" INTEGER, + "app_uid" TEXT, + "legacy_token_uid" TEXT, + "created_via" TEXT, + "auth_id" TEXT, + "access_token_uid" TEXT, + FOREIGN KEY("user_id") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +INSERT INTO `sessions_new` ( + `id`, `user_id`, `uuid`, `meta`, `created_at`, `last_activity`, + `kind`, `label`, `parent_session_id`, `last_ip`, `last_user_agent`, + `revoked_at`, `expires_at`, `app_uid`, `legacy_token_uid`, + `created_via`, `auth_id`, `access_token_uid` +) +SELECT + `id`, `user_id`, `uuid`, `meta`, `created_at`, `last_activity`, + `kind`, `label`, `parent_session_id`, `last_ip`, `last_user_agent`, + `revoked_at`, `expires_at`, `app_uid`, `legacy_token_uid`, + `created_via`, `auth_id`, `access_token_uid` +FROM `sessions`; + +DROP TABLE `sessions`; + +ALTER TABLE `sessions_new` RENAME TO `sessions`; + +CREATE INDEX IF NOT EXISTS `idx_sessions_user_revoked` + ON `sessions` (`user_id`, `revoked_at`); + +CREATE INDEX IF NOT EXISTS `idx_sessions_parent` + ON `sessions` (`parent_session_id`); + +CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_user_app_active` + ON `sessions` (`user_id`, `app_uid`) + WHERE `kind` = 'app' AND `revoked_at` IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_legacy_token_active` + ON `sessions` (`legacy_token_uid`) + WHERE `legacy_token_uid` IS NOT NULL AND `revoked_at` IS NULL; + +CREATE INDEX IF NOT EXISTS `idx_sessions_kind_user` + ON `sessions` (`kind`, `user_id`); + +CREATE INDEX IF NOT EXISTS `idx_sessions_access_token_uid` + ON `sessions` (`access_token_uid`) + WHERE `access_token_uid` IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_user_worker_active` + ON `sessions` (`user_id`, IFNULL(`app_uid`, ''), json_extract(`meta`, '$.worker_name')) + WHERE `kind` = 'worker' AND `revoked_at` IS NULL; diff --git a/src/backend/stores/session/SessionStore.js b/src/backend/stores/session/SessionStore.js index 0a988d0e7e..9265e36c91 100644 --- a/src/backend/stores/session/SessionStore.js +++ b/src/backend/stores/session/SessionStore.js @@ -48,6 +48,26 @@ const TOUCH_THROTTLE_MAX_ENTRIES = 10000; export const WEB_WINDOW_SECONDS = 365 * 24 * 60 * 60; // 1y export const APP_WINDOW_SECONDS = 365 * 24 * 60 * 60; // 1y export const WORKER_WINDOW_SECONDS = 99 * 365 * 24 * 60 * 60; // 99y (virtually infinite); + +// Duplicate-key error codes used by the `getOrCreate*` paths to detect +// "another caller won the partial-unique-index race" — the only error +// category they're prepared to silently no-op through. CHECK / NOT NULL / +// FK / type violations must bubble up; otherwise the caller caches a +// row that was never inserted. +// better-sqlite3 surfaces SqliteError.code; mysql2 surfaces .code and +// .errno (1062 = ER_DUP_ENTRY). +function isUniqueViolation(err) { + if (!err) return false; + const code = err.code; + if ( + code === 'SQLITE_CONSTRAINT_UNIQUE' || + code === 'SQLITE_CONSTRAINT_PRIMARYKEY' || + code === 'ER_DUP_ENTRY' + ) { + return true; + } + return err.errno === 1062; +} export const ASSET_WINDOW_SECONDS = 7 * 24 * 60 * 60; // 7 days const sqlTimestamp = (ms) => @@ -140,10 +160,10 @@ export class SessionStore extends PuterStore { /** * Shared INSERT implementation for `create()` and the idempotent - * `getOrCreate*` paths. `ignoreConflict: true` switches to engine- - * specific INSERT-IGNORE so partial-unique-index collisions silently - * no-op rather than throw — the idempotent callers handle the "row - * already existed" path via a re-SELECT. + * `getOrCreate*` paths. With `ignoreConflict: true`, only a duplicate- + * key error from the partial unique indexes is swallowed (concurrent + * caller won the race); every other failure — CHECK, NOT NULL, FK, + * type — throws. The caller then re-SELECTs to find the winning row. */ async #insertSession( userId, @@ -169,34 +189,45 @@ export class SessionStore extends PuterStore { meta.created = new Date().toISOString(); meta.created_unix = now; - const insertVerb = ignoreConflict - ? this.clients.db.case({ - sqlite: 'INSERT OR IGNORE INTO', - otherwise: 'INSERT IGNORE INTO', - }) - : 'INSERT INTO'; - - await this.clients.db.write( - `${insertVerb} \`sessions\` (\`uuid\`, \`user_id\`, \`meta\`, \`last_activity\`, \`created_at\`, \`kind\`, \`label\`, \`parent_session_id\`, \`last_ip\`, \`last_user_agent\`, \`expires_at\`, \`app_uid\`, \`legacy_token_uid\`, \`access_token_uid\`, \`created_via\`, \`auth_id\`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - uuid, - userId, - JSON.stringify(meta), - now, - now, - kind, - label, - parent_session_id, - last_ip, - last_user_agent, - expires_at, - app_uid, - legacy_token_uid, - access_token_uid, - created_via, - auth_id, - ], - ); + // Always issue a plain INSERT. The `getOrCreate*` paths set + // ignoreConflict=true so the partial-unique-index race against a + // concurrent caller can no-op, but only that specific error class + // is swallowed — every other failure (CHECK, NOT NULL, FK, type) + // bubbles up. Engine-specific INSERT-IGNORE swallowed all + // constraint violations, which masked schema bugs as "row didn't + // appear in the DB but the caller cached a synthetic row anyway". + try { + await this.clients.db.write( + `INSERT INTO \`sessions\` (\`uuid\`, \`user_id\`, \`meta\`, \`last_activity\`, \`created_at\`, \`kind\`, \`label\`, \`parent_session_id\`, \`last_ip\`, \`last_user_agent\`, \`expires_at\`, \`app_uid\`, \`legacy_token_uid\`, \`access_token_uid\`, \`created_via\`, \`auth_id\`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + uuid, + userId, + JSON.stringify(meta), + now, + now, + kind, + label, + parent_session_id, + last_ip, + last_user_agent, + expires_at, + app_uid, + legacy_token_uid, + access_token_uid, + created_via, + auth_id, + ], + ); + } catch (err) { + if (!ignoreConflict || !isUniqueViolation(err)) { + throw err; + } + // Concurrent caller won the partial-unique-index race. The + // caller's re-SELECT will return that winning row; the local + // `row` object below is a placeholder for the caller's + // `winner ?? created` fallback shape and is never cached on + // the ignoreConflict path. + } const row = { uuid,