feat: worker sessions get their own kind + per-(user, app, worker_name) row (#3160)

* feat: worker sessions get their own kind + per-(user, app, worker_name) row

Schema
------
- mysql_mig_11.sql + sqlite 0054: add idx_sessions_user_worker_active,
  a partial unique index over (user_id, app_uid, meta.worker_name) for
  kind='worker' rows. Active worker sessions are deduped by that triple
  so each named worker gets its own session row and they don't fight
  the existing idx_sessions_user_app_active (which still constrains
  kind='app' only). app_uid is allowed NULL for user-scoped workers
  with no app binding.

SessionStore
------------
- getOrCreateWorker(userId, { appUid, workerName, ... }): mirrors the
  getOrCreateApp pattern — cache lookup, partial-unique re-SELECT on
  insert-ignore, all keyed on the worker triple. expires_at lands at
  WORKER_WINDOW_SECONDS (~99y) so the worker doesn't have to re-mint
  on any cadence.
- #cacheKeyWorker + #allCacheKeysForRow worker branch so revoke /
  update invalidates the worker cache view alongside the by-uuid one.

AuthService
-----------
- createWorkerSessionToken(user, workerName, meta?) now takes the
  workerName explicitly and routes through getOrCreateWorker. Emits
  the same { session, token, gui_token } shape but both JWTs carry
  { worker: true, worker_name }.
- createWorkerAppToken(actor, appUid, workerName) likewise — JWT
  carries the worker_name claim so a verifier can tell two workers
  under the same app apart without a DB round-trip.
- Both methods 400 on empty workerName.

WorkerDriver
------------
- Five auth-mint call sites swapped over: app-bound deploy (3x:
  appId branch, actor.app fallback, hot-reload redeploy), user-bound
  fallback (2x: cold deploy, hot-reload). All pass `workerName` so
  the worker's session row is naturally idempotent across redeploys.

GUI manage-sessions
-------------------
- sessionTitle adds a kind='worker' branch ("name (app)" for
  app-scoped workers, just "name" for user-scoped), pulling worker_name
  from the meta-spread that listSessions already surfaces.
- en.js adds ui_session_kind_worker.

* fix(workers): MySQL JSON_EXTRACT quoting + revoke cache invalidation +
SQLite NULL-distinct in worker index

Three real bugs in the worker session plumbing from the prior commit,
plus a misleading comment. Schema design kept (worker_name lives in
`meta` rather than a dedicated column) per offline review:

1. `#selectWorkerRow` compared `JSON_EXTRACT(meta, '$.worker_name')`
   directly to a bind parameter. MySQL's `JSON_EXTRACT` returns a
   JSON-typed value with embedded quotes (`"name"`, not `name`), so
   the comparison never matched. After the first INSERT, every
   follow-up getOrCreateWorker call missed the existing row in the
   SELECT, hit INSERT-IGNORE, then missed again in the re-SELECT —
   the caller would receive whatever the INSERT-IGNORE returned (a
   no-op row in conflict cases). Wrap with `JSON_UNQUOTE` on MySQL
   via `db.case`; SQLite's `json_extract` already returns the
   unwrapped scalar so it keeps the literal form.

2. mig_11's generated `worker_unique_key` had the same JSON-quoting
   bug. Mirror the fix: `IFNULL(JSON_UNQUOTE(JSON_EXTRACT(...)), '')`
   so the concatenated unique key is a plain string that lines up
   with what `#selectWorkerRow` now binds against.

3. SQLite UNIQUE indexes treat NULL columns as distinct (per the SQL
   standard), so two user-scoped workers (app_uid NULL) with the same
   worker_name would both insert. Wrap the index expression with
   `IFNULL(app_uid, '')` so they correctly conflict — matches the
   MySQL side's `IFNULL` in the generated column.

4. `removeByUuid` / `revokeCascade` SELECTed only the identity
   columns (no `meta`), so `#allCacheKeysForRow`'s worker branch
   couldn't read `meta.worker_name` and the composite
   `sessions:v2:worker:<user>:<app>:<name>` cache key survived
   revocation. Up to CACHE_TTL_SECONDS (15min) afterwards,
   getOrCreateWorker would short-circuit to the cached (revoked) row.
   Add `meta` to both SELECTs; the existing meta-parsing logic in
   `#allCacheKeysForRow` handles the rest.
This commit is contained in:
Daniel Salazar
2026-05-26 20:05:31 -07:00
committed by GitHub
parent 971a1b5071
commit bd91f5e192
8 changed files with 494 additions and 81 deletions
@@ -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 <https://www.gnu.org/licenses/>.
-- 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;
@@ -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 <https://www.gnu.org/licenses/>.
-- 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;
+30 -14
View File
@@ -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;
}
+162 -27
View File
@@ -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<string, unknown>) =>
(typeof row.meta === 'string'
? (JSON.parse(row.meta as string) as Record<string, unknown>)
: (row.meta as Record<string, unknown>)) ?? {};
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<string, unknown>;
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<string, unknown>);
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<string, unknown>;
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<string, unknown>);
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<string, unknown>;
const workerRow = (await server.stores.session.getByUuid(
workerSessionUid,
)) as Record<string, unknown>;
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', () => {
+56 -37
View File
@@ -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<string, unknown> = {
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<string, unknown> = {},
): Promise<{
session: Record<string, unknown>;
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<string> {
async createWorkerAppToken(
actor: Actor,
appUid: string,
workerName: string,
): Promise<string> {
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,
});
}
+137 -3
View File
@@ -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(
+8
View File
@@ -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';
}
+1
View File
@@ -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',