Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/backend/clients/database/SqliteDatabaseClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
50 changes: 50 additions & 0 deletions src/backend/clients/database/migrations/mysql/mysql_mig_12.sql
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

-- 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;
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

-- 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;
95 changes: 63 additions & 32 deletions src/backend/stores/session/SessionStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading