mirror of
https://github.com/HeyPuter/puter.git
synced 2026-07-08 16:21:40 +00:00
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:
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user