diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_11.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_11.sql new file mode 100644 index 000000000..c824d7ad3 --- /dev/null +++ b/src/backend/clients/database/migrations/mysql/mysql_mig_11.sql @@ -0,0 +1,70 @@ +-- 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 . + +-- Worker session uniqueness. Each Puter worker is a separately-deployed +-- code unit (its own subdomain), so one user can have many workers under +-- the same app — distinguished by `meta.worker_name`. The natural unique +-- key for an active worker session is therefore +-- (user_id, app_uid, worker_name), and app_uid is allowed NULL for +-- user-scoped workers that aren't bound to any specific app. +-- +-- Implemented the same way as `app_unique_key` from mig_9: a VIRTUAL +-- generated column that's non-NULL only for the rows under the rule, +-- then a UNIQUE INDEX on the column. NULLs don't conflict in MySQL +-- UNIQUE indexes, so soft-revoked / non-worker rows fall out +-- automatically. IFNULL normalises NULL `app_uid` so two user-scoped +-- workers with the same name still dedupe. JSON_UNQUOTE strips the +-- JSON value quoting from JSON_EXTRACT so the concatenated key is a +-- plain string that matches the SELECT path's binding. +-- +-- Idempotent: each ADD COLUMN / ADD INDEX is guarded so the migration +-- directory can be replayed safely. + +DROP PROCEDURE IF EXISTS _puter_sessions_worker_unique; +DELIMITER // +CREATE PROCEDURE _puter_sessions_worker_unique() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'worker_unique_key' + ) THEN + ALTER TABLE `sessions` + ADD COLUMN `worker_unique_key` VARCHAR(550) + GENERATED ALWAYS AS ( + IF(`kind` = 'worker' AND `revoked_at` IS NULL, + CONCAT(`user_id`, '|', IFNULL(`app_uid`, ''), '|', + IFNULL(JSON_UNQUOTE(JSON_EXTRACT(`meta`, '$.worker_name')), '')), + NULL) + ) VIRTUAL; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sessions' + AND INDEX_NAME = 'idx_sessions_user_worker_active' + ) THEN + ALTER TABLE `sessions` + ADD UNIQUE INDEX `idx_sessions_user_worker_active` (`worker_unique_key`); + END IF; +END// +DELIMITER ; + +CALL _puter_sessions_worker_unique(); + +DROP PROCEDURE IF EXISTS _puter_sessions_worker_unique; diff --git a/src/backend/clients/database/migrations/sqlite/0054_sessions_workers.sql b/src/backend/clients/database/migrations/sqlite/0054_sessions_workers.sql new file mode 100644 index 000000000..bf42e0715 --- /dev/null +++ b/src/backend/clients/database/migrations/sqlite/0054_sessions_workers.sql @@ -0,0 +1,30 @@ +-- 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 . + +-- Worker session uniqueness. One active kind='worker' row per +-- (user_id, app_uid, worker_name). +-- +-- SQLite UNIQUE indexes treat NULLs as distinct (per the SQL standard), +-- so user-scoped workers (where `app_uid` is NULL) would otherwise be +-- allowed to duplicate. IFNULL collapses NULL `app_uid` to empty +-- string in the index expression so two user-scoped workers with the +-- same worker_name correctly conflict. MySQL handles the same case +-- via the IFNULL in mig_11's generated column. + +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/drivers/workers/WorkerDriver.ts b/src/backend/drivers/workers/WorkerDriver.ts index 114462b80..d11f9a5c2 100644 --- a/src/backend/drivers/workers/WorkerDriver.ts +++ b/src/backend/drivers/workers/WorkerDriver.ts @@ -150,7 +150,11 @@ export class WorkerDriver extends PuterDriver { ); } - // If tied to an app, verify ownership and get app-scoped token + // If tied to an app, verify ownership and get an app-scoped + // worker token. Worker tokens use `kind='worker'` so they don't + // collide with any interactive `kind='app'` session for the + // same (user, app); the long expiry (WORKER_WINDOW_SECONDS) + // means the worker doesn't have to re-mint on a clock cadence. let authorization = String(args.authorization ?? ''); let appOwnerId = actor.app?.id ?? undefined; if (appId) { @@ -162,15 +166,17 @@ export class WorkerDriver extends PuterDriver { ); } appOwnerId = actor.app?.id; - authorization = await this.services.auth.getUserAppToken( + authorization = await this.services.auth.createWorkerAppToken( actor, appId, + workerName, ); } if (!authorization && actor.app?.uid) { - authorization = await this.services.auth.getUserAppToken( + authorization = await this.services.auth.createWorkerAppToken( actor, actor.app.uid, + workerName, ); } @@ -186,14 +192,18 @@ export class WorkerDriver extends PuterDriver { ); } if (!authorization) { - // Fall back to a session token for the current user + // Fall back to a user-scoped worker token (no app binding). + // Same kind='worker' row + long expiry as the app-scoped + // branch above; (user, worker_name) is the unique key. const userRow = await this.stores.user.getById(actor.user.id!); if (!userRow) throw new HttpError(500, 'User not found', { legacyCode: 'internal_error', }); - const session = - await this.services.auth.createSessionToken(userRow); + const session = await this.services.auth.createWorkerSessionToken( + userRow, + workerName, + ); authorization = session.token; } @@ -581,21 +591,27 @@ export class WorkerDriver extends PuterDriver { ); const sourceCode = loaded.buffer.toString('utf-8'); - // Get an auth token for the deploy + // Mint a worker token for the redeploy. Idempotent on + // (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. const appOwnerId = row.app_owner as number | null; let authorization: string; if (appOwnerId) { - // App-scoped: get the app's uid, then mint an app-under-user token const app = await this.stores.app.getById(appOwnerId); if (!app) continue; // app gone - authorization = await this.services.auth.getUserAppToken( - ownerActor, - app.uid, - ); + authorization = + await this.services.auth.createWorkerAppToken( + ownerActor, + app.uid, + workerName, + ); } else { - // User-scoped: mint a session token const session = - await this.services.auth.createSessionToken(ownerUser); + await this.services.auth.createWorkerSessionToken( + ownerUser, + workerName, + ); authorization = session.token; } diff --git a/src/backend/services/auth/AuthService.test.ts b/src/backend/services/auth/AuthService.test.ts index 8057f2755..3f0269e3c 100644 --- a/src/backend/services/auth/AuthService.test.ts +++ b/src/backend/services/auth/AuthService.test.ts @@ -670,46 +670,85 @@ describe('AuthService (integration)', () => { >; }; - it('createWorkerSessionToken mints a kind="web" row tagged meta.worker, with the WORKER_WINDOW_SECONDS expiry', async () => { + const readMeta = (row: Record) => + (typeof row.meta === 'string' + ? (JSON.parse(row.meta as string) as Record) + : (row.meta as Record)) ?? {}; + + it('createWorkerSessionToken mints a kind="worker" row tagged meta.worker_name with the WORKER_WINDOW_SECONDS expiry', async () => { const user = await makeUser(); const before = Math.floor(Date.now() / 1000); + const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`; const { session, token, gui_token } = - await authService.createWorkerSessionToken(user, { + await authService.createWorkerSessionToken(user, workerName, { user_agent: 'worker-agent', }); const row = (await server.stores.session.getByUuid( (session as { uuid: string }).uuid, )) as Record; - expect(row.kind).toBe('web'); + expect(row.kind).toBe('worker'); + expect(row.app_uid).toBeNull(); // expires_at lands in the ~99-year window — assert lower // bound only so the test isn't fragile to small drift or a // future constant adjustment. expect(row.expires_at as number).toBeGreaterThanOrEqual( before + 50 * 365 * 24 * 60 * 60, ); - const meta = - typeof row.meta === 'string' - ? (JSON.parse(row.meta as string) as Record< - string, - unknown - >) - : (row.meta as Record); + const meta = readMeta(row); expect(meta.worker).toBe(true); + expect(meta.worker_name).toBe(workerName); - // Both JWTs carry the worker claim so downstream code can - // distinguish without re-reading the session row. + // Both JWTs carry the worker + worker_name claims so + // downstream code can distinguish without a DB hit. expect(decodeAuth(token).worker).toBe(true); + expect(decodeAuth(token).worker_name).toBe(workerName); expect(decodeAuth(gui_token).worker).toBe(true); + expect(decodeAuth(gui_token).worker_name).toBe(workerName); }); - it('createWorkerAppToken mints a kind="app" row tagged meta.worker, with the WORKER_WINDOW_SECONDS expiry', async () => { + it('createWorkerSessionToken is idempotent on (user, worker_name) — redeploys reuse the row', async () => { + const user = await makeUser(); + const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`; + const a = await authService.createWorkerSessionToken( + user, + workerName, + ); + const b = await authService.createWorkerSessionToken( + user, + workerName, + ); + expect((a.session as { uuid: string }).uuid).toBe( + (b.session as { uuid: string }).uuid, + ); + }); + + it('createWorkerSessionToken with different worker_names mints distinct rows for the same user', async () => { + const user = await makeUser(); + const a = await authService.createWorkerSessionToken( + user, + `wk-${Math.random().toString(36).slice(2, 8)}-a`, + ); + const b = await authService.createWorkerSessionToken( + user, + `wk-${Math.random().toString(36).slice(2, 8)}-b`, + ); + expect((a.session as { uuid: string }).uuid).not.toBe( + (b.session as { uuid: string }).uuid, + ); + }); + + it('createWorkerSessionToken rejects an empty workerName (400)', async () => { + const user = await makeUser(); + await expect( + authService.createWorkerSessionToken(user, ''), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('createWorkerAppToken mints a kind="worker" row with worker_name + WORKER_WINDOW_SECONDS expiry', async () => { const user = await makeUser(); - // Existing AuthService surfaces (e.g. getUserAppToken in the - // tests below) shape app_uid as `app-${uuid}` — keep the - // same shape here so any downstream validator that asserts - // on the hyphenated form doesn't reject the row. const appUid = `app-${uuidv4()}`; + const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`; const actor = { user: { id: user.id, uuid: user.uuid, username: user.username }, } as Actor; @@ -717,31 +756,112 @@ describe('AuthService (integration)', () => { const token = await authService.createWorkerAppToken( actor, appUid, + workerName, ); const decoded = decodeAuth(token); expect(decoded.type).toBe('app-under-user'); expect(decoded.worker).toBe(true); + expect(decoded.worker_name).toBe(workerName); expect(decoded.app_uid).toBe(appUid); expect(decoded.user_uid).toBe(user.uuid); - const sessionUid = decoded.session_uid as string; const row = (await server.stores.session.getByUuid( - sessionUid, + decoded.session_uid as string, )) as Record; - expect(row.kind).toBe('app'); + expect(row.kind).toBe('worker'); expect(row.app_uid).toBe(appUid); expect(row.expires_at as number).toBeGreaterThanOrEqual( before + 50 * 365 * 24 * 60 * 60, ); - const meta = - typeof row.meta === 'string' - ? (JSON.parse(row.meta as string) as Record< - string, - unknown - >) - : (row.meta as Record); + const meta = readMeta(row); expect(meta.worker).toBe(true); + expect(meta.worker_name).toBe(workerName); + }); + + it('createWorkerAppToken is idempotent on (user, app, worker_name)', async () => { + const user = await makeUser(); + const appUid = `app-${uuidv4()}`; + const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`; + const actor = { + user: { id: user.id, uuid: user.uuid, username: user.username }, + } as Actor; + const a = await authService.createWorkerAppToken( + actor, + appUid, + workerName, + ); + const b = await authService.createWorkerAppToken( + actor, + appUid, + workerName, + ); + expect((decodeAuth(a) as { session_uid: string }).session_uid).toBe( + (decodeAuth(b) as { session_uid: string }).session_uid, + ); + }); + + it('createWorkerAppToken coexists with an interactive app session for the same (user, app)', async () => { + // The point of `kind="worker"` is precisely to avoid the + // `idx_sessions_user_app_active` collision that bit us + // pre-schema-carve-out. Verify: getUserAppToken creates the + // interactive `kind="app"` row, then createWorkerAppToken + // for the SAME (user, app) succeeds and yields a distinct + // row with kind="worker". + const user = await makeUser(); + const appUid = `app-${uuidv4()}`; + const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`; + const actor = { + user: { id: user.id, uuid: user.uuid, username: user.username }, + } as Actor; + const interactiveJwt = await authService.getUserAppToken( + actor, + appUid, + ); + const interactiveDecoded = decodeAuth(interactiveJwt); + const interactiveSessionUid = + interactiveDecoded.session_uid as string; + + const workerJwt = await authService.createWorkerAppToken( + actor, + appUid, + workerName, + ); + const workerDecoded = decodeAuth(workerJwt); + const workerSessionUid = workerDecoded.session_uid as string; + + expect(workerSessionUid).not.toBe(interactiveSessionUid); + + const interactiveRow = (await server.stores.session.getByUuid( + interactiveSessionUid, + )) as Record; + const workerRow = (await server.stores.session.getByUuid( + workerSessionUid, + )) as Record; + expect(interactiveRow.kind).toBe('app'); + expect(workerRow.kind).toBe('worker'); + expect(workerRow.app_uid).toBe(appUid); + }); + + it('createWorkerAppToken with different worker_names under the same (user, app) mints distinct rows', async () => { + const user = await makeUser(); + const appUid = `app-${uuidv4()}`; + const actor = { + user: { id: user.id, uuid: user.uuid, username: user.username }, + } as Actor; + const a = await authService.createWorkerAppToken( + actor, + appUid, + `wk-${Math.random().toString(36).slice(2, 8)}-a`, + ); + const b = await authService.createWorkerAppToken( + actor, + appUid, + `wk-${Math.random().toString(36).slice(2, 8)}-b`, + ); + expect((decodeAuth(a) as { session_uid: string }).session_uid).not.toBe( + (decodeAuth(b) as { session_uid: string }).session_uid, + ); }); it('createWorkerAppToken refuses an actor with no user (403)', async () => { @@ -749,9 +869,24 @@ describe('AuthService (integration)', () => { authService.createWorkerAppToken( { user: undefined } as unknown as Actor, 'app-x', + 'wk-x', ), ).rejects.toMatchObject({ statusCode: 403 }); }); + + it('createWorkerAppToken rejects an empty workerName (400)', async () => { + const user = await makeUser(); + const actor = { + user: { id: user.id, uuid: user.uuid, username: user.username }, + } as Actor; + await expect( + authService.createWorkerAppToken( + actor, + `app-${uuidv4()}`, + '', + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); }); describe('appUidFromOrigin', () => { diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts index 9b367485f..098cacacf 100644 --- a/src/backend/services/auth/AuthService.ts +++ b/src/backend/services/auth/AuthService.ts @@ -24,7 +24,6 @@ import { checkRateLimit } from '../../core/http/middleware/rateLimit.js'; import { ASSET_WINDOW_SECONDS, WEB_WINDOW_SECONDS, - WORKER_WINDOW_SECONDS, } from '../../stores/session/SessionStore.js'; import type { UserRow } from '../../stores/user/UserStore'; import type { LayerInstances } from '../../types'; @@ -198,7 +197,7 @@ export class AuthService extends PuterService { user: UserRow, sessionUuid: string, authId: string, - opts: { worker?: boolean } = {}, + opts: { worker?: boolean; workerName?: string } = {}, ): string { const claims: Record = { type, @@ -212,82 +211,101 @@ export class AuthService extends PuterService { auth_id: authId, }; if (opts.worker) claims.worker = true; + if (opts.workerName) claims.worker_name = opts.workerName; return this.services.token.sign('auth', claims); } /** - * Worker variant of `createSessionToken`. Mints a new `kind='web'` - * session row tagged `meta.worker = true` and expiring after - * `WORKER_WINDOW_SECONDS` (vs. WEB_WINDOW_SECONDS for an interactive - * session). The emitted JWT carries `worker: true` so downstream - * code can tell a worker session from a user-driven one without a - * DB round-trip. Same return shape as createSessionToken. + * Worker variant of `createSessionToken` for user-scoped workers + * (not bound to any specific app). Idempotent on + * (user_id, worker_name) via the `kind='worker'` partial unique + * index — redeploying the same worker reuses the row and returns + * the same stable token. Expires after `WORKER_WINDOW_SECONDS` + * (effectively infinite). The emitted JWT carries `worker: true` + * and `worker_name` so downstream code can tell a worker session + * from a user-driven one without a DB round-trip. */ async createWorkerSessionToken( user: UserRow, + workerName: string, meta: Record = {}, ): Promise<{ session: Record; token: string; gui_token: string; }> { + if (!workerName) { + throw new HttpError(400, 'Missing `workerName`', { + legacyCode: 'bad_request', + }); + } const auth_id = this.#authIdFor(user); - const session = await this.stores.session.create(user.id, { - meta: { ...meta, worker: true }, - kind: 'web', + const session = await this.stores.session.getOrCreateWorker(user.id, { + appUid: null, + workerName, + meta, last_ip: (meta.ip as string | undefined) ?? null, last_user_agent: (meta.user_agent as string | undefined) ?? null, - expires_at: nowSeconds() + WORKER_WINDOW_SECONDS, auth_id, }); + if (!session) { + throw new HttpError(500, 'Worker session create failed', { + legacyCode: 'internal_error', + }); + } const token = this.#signSessionTypeToken( 'session', user, - session.uuid, + session.uuid as string, auth_id, - { worker: true }, + { worker: true, workerName }, ); const gui_token = this.#signSessionTypeToken( 'gui', user, - session.uuid, + session.uuid as string, auth_id, - { worker: true }, + { worker: true, workerName }, ); return { session, token, gui_token }; } /** - * Worker variant of `getUserAppToken`. Bypasses the idempotent - * `getOrCreateApp` path (which would return the existing - * interactive app session at WEB/APP_WINDOW_SECONDS) and creates a - * fresh `kind='app'` row tagged `meta.worker = true` with a - * `WORKER_WINDOW_SECONDS` expiry. The emitted JWT is shaped like a - * standard app-under-user token plus a `worker: true` claim so the - * downstream consumer can tell them apart. - * - * NOTE: the v2 `idx_sessions_user_app_active` index ensures one - * active app row per (user, app). A worker session here will - * collide with an existing non-worker app session for the same - * (user, app) pair. Future schema work can carve workers out of - * that uniqueness; for now callers must accept that constraint. + * Worker variant of `getUserAppToken` for app-scoped workers. + * Idempotent on (user_id, app_uid, worker_name) via the + * `kind='worker'` partial unique index — the same app can host + * many workers distinguished by name, each getting its own + * stable long-lived token. Coexists with an interactive + * `kind='app'` session for the same (user, app) because the + * uniqueness keys don't overlap. */ - async createWorkerAppToken(actor: Actor, appUid: string): Promise { + async createWorkerAppToken( + actor: Actor, + appUid: string, + workerName: string, + ): Promise { if (!actor.user) { throw new HttpError(403, 'Actor must be a user', { legacyCode: 'forbidden', }); } + if (!workerName) { + throw new HttpError(400, 'Missing `workerName`', { + legacyCode: 'bad_request', + }); + } const auth_id = this.#authIdFor(actor.user as UserRow); - const session = await this.stores.session.create(actor.user.id, { - meta: { worker: true }, - kind: 'app', - app_uid: appUid, - expires_at: nowSeconds() + WORKER_WINDOW_SECONDS, - auth_id, - }); + const session = await this.stores.session.getOrCreateWorker( + actor.user.id, + { appUid, workerName, auth_id }, + ); + if (!session) { + throw new HttpError(500, 'Worker session create failed', { + legacyCode: 'internal_error', + }); + } return this.services.token.sign('auth', { type: 'app-under-user', @@ -297,6 +315,7 @@ export class AuthService extends PuterService { session_uid: session.uuid, auth_id, worker: true, + worker_name: workerName, }); } diff --git a/src/backend/stores/session/SessionStore.js b/src/backend/stores/session/SessionStore.js index c7d81d246..8d51603d6 100644 --- a/src/backend/stores/session/SessionStore.js +++ b/src/backend/stores/session/SessionStore.js @@ -47,7 +47,7 @@ const TOUCH_THROTTLE_MAX_ENTRIES = 10000; // Exported so AuthService can use the same values when seeding new rows. 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); TODO DS: have workers pass in flag when creating worker token so that we can give them infinite time +export const WORKER_WINDOW_SECONDS = 99 * 365 * 24 * 60 * 60; // 99y (virtually infinite); export const ASSET_WINDOW_SECONDS = 7 * 24 * 60 * 60; // 7 days const sqlTimestamp = (ms) => @@ -244,8 +244,13 @@ export class SessionStore extends PuterStore { // SELECT first so we know which composite cache keys point at // this row. A single UPDATE ... RETURNING would be cleaner but // isn't portable between sqlite/mysql. + // `meta` included so #allCacheKeysForRow can read + // meta.worker_name for the worker cache key; without it the + // composite worker cache entry would survive revocation and + // getOrCreateWorker would serve the stale (revoked) row for + // up to CACHE_TTL_SECONDS. const rows = await this.clients.db.read( - 'SELECT `uuid`, `user_id`, `kind`, `app_uid`, `legacy_token_uid` FROM `sessions` WHERE `uuid` = ? AND `revoked_at` IS NULL LIMIT 1', + 'SELECT `uuid`, `user_id`, `kind`, `app_uid`, `legacy_token_uid`, `meta` FROM `sessions` WHERE `uuid` = ? AND `revoked_at` IS NULL LIMIT 1', [uuid], ); if (rows.length === 0) return; @@ -274,7 +279,7 @@ export class SessionStore extends PuterStore { // alongside the uuid key, otherwise a follow-up `getOrCreateApp` // would short-circuit to the freshly-revoked row. const rows = await this.clients.db.read( - 'SELECT `uuid`, `user_id`, `kind`, `app_uid`, `legacy_token_uid` FROM `sessions` WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `revoked_at` IS NULL', + 'SELECT `uuid`, `user_id`, `kind`, `app_uid`, `legacy_token_uid`, `meta` FROM `sessions` WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `revoked_at` IS NULL', [rootUuid, rootUuid], ); if (rows.length === 0) return; @@ -371,6 +376,79 @@ export class SessionStore extends PuterStore { return row; } + /** + * Idempotent "give me the worker session for this (user, app, + * worker_name)" lookup. Same shape as `getOrCreateApp` but keyed on + * a triple so multiple workers can sit under the same app (each + * with its own `worker_name`) and each gets its own session row. + * + * `appUid` is allowed null for user-scoped workers — the partial + * unique index treats those distinctly (SQLite via NULL-distinct + * semantics, MySQL via IFNULL in the generated key). + * + * @param userId - User row id (numeric). + * @param opts.appUid - App UID or null for user-scoped workers. + * @param opts.workerName - Per-worker discriminator. Required. + * @param opts.meta - Additional metadata merged into the row's + * `meta` blob alongside the canonical `worker: true` and + * `worker_name` markers. + * @param opts.last_ip / opts.last_user_agent - Request context for + * first-time creation. Ignored when a row already exists. + * @param opts.auth_id - Stable per-user identity (survives re-login). + */ + async getOrCreateWorker(userId, opts = {}) { + if (!userId || !opts.workerName) return null; + const appUid = opts.appUid ?? null; + const workerName = String(opts.workerName); + + const cacheKey = this.#cacheKeyWorker(userId, appUid, workerName); + const now = nowSeconds(); + + const cached = await this.#readCacheKey(cacheKey); + if (cached && cached.revoked_at == null && !isExpired(cached, now)) { + return cached; + } + + const existing = await this.#selectWorkerRow( + userId, + appUid, + workerName, + ); + if (existing) { + await this.#writeCacheKey(cacheKey, existing); + this.#writeCache(existing).catch(() => {}); + return existing; + } + + const created = await this.#insertSession( + userId, + { + kind: 'worker', + app_uid: appUid, + parent_session_id: null, + last_ip: opts.last_ip ?? null, + last_user_agent: opts.last_user_agent ?? null, + expires_at: now + WORKER_WINDOW_SECONDS, + auth_id: opts.auth_id ?? null, + meta: { + ...(opts.meta ?? {}), + worker: true, + worker_name: workerName, + }, + }, + { ignoreConflict: true }, + ); + + // INSERT-IGNORE may have lost the race against another caller; + // re-SELECT under the partial unique index to find whichever + // row actually won. + const winner = await this.#selectWorkerRow(userId, appUid, workerName); + const row = winner ?? created; + await this.#writeCacheKey(cacheKey, row); + this.#writeCache(row).catch(() => {}); + return row; + } + /** * Idempotent "give me the lazy-backfill row for this v1 token_uid" * lookup. Mirrors `getOrCreateApp` but keys on `legacy_token_uid`. @@ -565,6 +643,15 @@ export class SessionStore extends PuterStore { return `${CACHE_KEY_PREFIX}:legacy-at:${tokenUid}`; } + /** + * Cache key for the (user, app, worker_name) worker-session triple. + * `app_uid` is encoded as empty-string for user-scoped workers so + * the namespace doesn't fracture on NULL. + */ + #cacheKeyWorker(userId, appUid, workerName) { + return `${CACHE_KEY_PREFIX}:worker:${userId}:${appUid ?? ''}:${workerName}`; + } + /** * Cache key for the (legacy-web) backfill lookup. IP and UA are * percent-encoded into a single key segment so a UA containing `:` @@ -586,6 +673,28 @@ export class SessionStore extends PuterStore { if (row.kind === 'app' && row.user_id && row.app_uid) { keys.push(this.#cacheKeyApp(row.user_id, row.app_uid)); } + if (row.kind === 'worker' && row.user_id) { + const meta = + typeof row.meta === 'string' + ? (() => { + try { + return JSON.parse(row.meta); + } catch { + return null; + } + })() + : row.meta; + const workerName = meta?.worker_name; + if (typeof workerName === 'string' && workerName) { + keys.push( + this.#cacheKeyWorker( + row.user_id, + row.app_uid ?? null, + workerName, + ), + ); + } + } if (row.legacy_token_uid) { keys.push(this.#cacheKeyLegacyAt(row.legacy_token_uid)); } @@ -638,6 +747,31 @@ export class SessionStore extends PuterStore { return this.#normalizeRow(rows[0]); } + /** + * Active worker session for (userId, appUid, workerName). Matches + * the partial unique index `idx_sessions_user_worker_active`. + * `appUid` is allowed null for user-scoped workers; IFNULL keeps + * the comparison correct since SQL `= NULL` doesn't match. + * + * MySQL's `JSON_EXTRACT` returns a JSON-typed value with embedded + * quoting (`"name"` rather than `name`), which never equals a + * plain string bind. Use `JSON_UNQUOTE(JSON_EXTRACT(...))` on + * MySQL to strip that. SQLite's `json_extract` already returns the + * unwrapped scalar so the literal form works there. + */ + async #selectWorkerRow(userId, appUid, workerName) { + const now = nowSeconds(); + const workerNameExpr = this.clients.db.case({ + sqlite: "json_extract(`meta`, '$.worker_name')", + otherwise: "JSON_UNQUOTE(JSON_EXTRACT(`meta`, '$.worker_name'))", + }); + const rows = await this.clients.db.read( + `SELECT * FROM \`sessions\` WHERE \`kind\` = 'worker' AND \`user_id\` = ? AND IFNULL(\`app_uid\`, '') = IFNULL(?, '') AND ${workerNameExpr} = ? AND \`revoked_at\` IS NULL AND (\`expires_at\` IS NULL OR \`expires_at\` > ?) LIMIT 1`, + [userId, appUid ?? null, workerName, now], + ); + return this.#normalizeRow(rows[0]); + } + async #selectLegacyAccessTokenRow(tokenUid) { const now = nowSeconds(); const rows = await this.clients.db.read( diff --git a/src/gui/src/UI/UIWindowManageSessions.js b/src/gui/src/UI/UIWindowManageSessions.js index ee8692421..c73005d06 100644 --- a/src/gui/src/UI/UIWindowManageSessions.js +++ b/src/gui/src/UI/UIWindowManageSessions.js @@ -64,6 +64,14 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) { }; const sessionTitle = (session) => { + if ( session.kind === 'worker' ) { + // Worker rows surface `worker_name` from meta. Show the + // worker's own name first, then the app it's bound to (if + // any) for context. + const name = session.worker_name || (i18n('ui_session_kind_worker') || 'Worker'); + const appPart = session.app?.title || session.app?.name; + return appPart ? `${name} (${appPart})` : name; + } if ( session.kind === 'app' ) { return session.app?.title || session.app?.name || i18n('ui_session_kind_app') || 'App session'; } diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index a10c187d7..65c6f30d8 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -368,6 +368,7 @@ const en = { ui_session_kind_access_token: 'Access token', ui_session_kind_app: 'App session', ui_session_kind_web: 'Browser session', + ui_session_kind_worker: 'Worker', ui_session_last_active: 'Last active', undo: 'Undo', unlimited: 'Unlimited',