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',