fix(workers): bind credentials and cleanup to the owning site (#4362)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

* fix(workers): bind credentials and cleanup to the owning site

* test(workers): deploy valid source in the site-bound token test

Where workerd is installed the deploy starts the script, and the bare
identifier `src` threw at startup as an unhandled rejection.
This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-10-28 17:31:15 -07:00
1 parent b19fde242f
commit 28be1851f9
15 files changed
+802 -43

No files matched your search

+1 -1
View File
@@ -711,7 +711,7 @@ export type EventMap = {
// ---- Subdomains ----
// `uid` is the row uuid — for `delete` it is the only surviving handle a
// listener can use to find state keyed to the row after it is gone.
'subdomain.delete': { subdomain: string; uid?: string };
'subdomain.delete': { subdomain: string; uid?: string; userId: number };
'subdomain.update': { subdomain: string; uid?: string };
'site.htmlServed': {
subdomain: string;
@@ -198,7 +198,7 @@ describe('HostingController /delete-site', () => {
);
expect(emitSpy).toHaveBeenCalledWith(
'subdomain.delete',
{ subdomain: site.subdomain, uid: String(site.uuid) },
{ subdomain: site.subdomain, uid: String(site.uuid), userId },
{},
);
} finally {
@@ -452,6 +452,7 @@ export class SubdomainDriver extends PuterDriver {
{
subdomain: row.subdomain as string,
uid: String(row.uuid),
userId: row.user_id as number,
},
{},
);
@@ -0,0 +1,107 @@
/**
* 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/](https://www.gnu.org/licenses/).
*/
import { v4 as uuidv4 } from 'uuid';
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from 'vitest';
import { makeActor } from '../../core/actor.js';
import { PuterServer } from '../../server.js';
import { WORKER_SUBDOMAIN_PREFIX } from '../../stores/subdomain/SubdomainStore.js';
import { setupTestServer } from '../../testUtil.js';
let server: PuterServer;
beforeAll(async () => {
// A deploy backend is configured so the driver subscribes to `subdomain.delete`.
server = await setupTestServer({
workers: { XAUTHKEY: 'cf-auth-key', ACCOUNTID: 'cf-account' },
} as never);
});
afterAll(async () => {
await server?.shutdown();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('WorkerDriver on an app delete cascade', () => {
it('deletes the session of a worker that cascades away with its app', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ success: true, errors: [] }),
} as unknown as Response);
const username = `wkcas${Math.random().toString(36).slice(2, 8)}`;
const user = await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
free_storage: 50 * 1024 * 1024,
requires_email_confirmation: false,
});
const app = await server.stores.app.create(
{
name: `wkcas-${uuidv4()}`,
title: 'Cascade',
index_url: `https://wkcas-${uuidv4()}.test/`,
},
{ ownerUserId: user.id },
);
const workerName = `cascade-${username}`;
const row = await server.stores.subdomain.create({
userId: user.id,
subdomain: `${WORKER_SUBDOMAIN_PREFIX}${workerName}`,
associatedAppId: app.id,
});
await server.services.auth.createWorkerSessionToken(
makeActor({ user }),
user,
workerName,
{},
{ workerUid: row.uuid },
);
const session = () =>
server.stores.session.getWorker(user.id, {
appUid: null,
workerName,
});
expect(await session()).toBeTruthy();
expect(await server.stores.app.delete(app.id)).toBe(true);
await vi.waitFor(async () => expect(await session()).toBeNull());
// SQLite reuses the deleted app's id; drop anything it left behind.
await server.clients.db.write(
'DELETE FROM `subdomains` WHERE `uuid` = ?',
[row.uuid],
);
});
});
@@ -52,6 +52,7 @@ import { resolveCallableMethods } from '../meta.js';
import {
INTERNAL_ADMIN_DESTROY,
INTERNAL_ADMISSION_BYPASS,
INTERNAL_DEPLOY_TARGET,
type WorkerDriver,
} from './WorkerDriver.js';
@@ -193,6 +194,27 @@ const workerSession = (userId: number, workerName: string) =>
// -- create ----------------------------------------------------------
describe('WorkerDriver.create with a configured deploy backend', () => {
it('retains its deployment backend after a separate local server starts', async () => {
const localServer = await setupTestServer({
workers: { localServer: true },
} as never);
await localServer.shutdown();
const { actor, user } = await makeUser();
const path = `/${user.username}/backend.js`;
await writeSource(actor, user.id, path, 'export default {}');
const name = `backend-${user.username}`;
expect(
await inCtx(actor, () =>
target.create({
appId: '',
workerName: name,
filePath: path,
}),
),
).toMatchObject({ success: true, url: `https://${name}.puter.work` });
expect(putCalls()).toHaveLength(1);
});
it('creates the subdomain row, deploys the source, and returns the worker URL', async () => {
const { user, actor } = await makeUser();
const path = `/${user.username}/worker.js`;
@@ -549,6 +571,62 @@ describe('WorkerDriver.create with a configured deploy backend', () => {
expect(Number(row!.root_dir_id)).toBe(ownEntry.id);
});
describe('to an internal deploy target', () => {
const deployTo = (actor: Actor, filePath: string, scriptName: string) =>
inCtx(actor, () =>
target.create({
appId: '',
workerName: scriptName,
filePath,
[INTERNAL_DEPLOY_TARGET]: { scriptName },
}),
);
it('deploys the deployer’s own file', async () => {
const { user, actor } = await makeUser();
const path = `/${user.username}/site.worker.js`;
await writeSource(actor, user.id, path, 'export default {}');
const scriptName = `dt-own-${user.username}`;
expect(await deployTo(actor, path, scriptName)).toMatchObject({
success: true,
});
expect(putCalls()).toHaveLength(1);
});
it('refuses a file another user owns, even with manage on it', async () => {
const owner = await makeUser();
const deployer = await makeUser();
const path = `/${owner.user.username}/site.worker.js`;
const entry = await writeSource(
owner.actor,
owner.user.id,
path,
'v1',
);
await server.services.acl.setUserUser(
owner.actor,
deployer.actor,
{
path: entry.path,
resolveAncestors: () =>
server.services.fs.getAncestorChain(entry.path),
},
'manage',
);
const scriptName = `dt-foreign-${deployer.user.username}`;
await expect(
deployTo(deployer.actor, path, scriptName),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'forbidden',
});
expect(putCalls()).toHaveLength(0);
expect(
await workerSession(deployer.user.id, scriptName),
).toBeNull();
});
});
it('surfaces an edge deploy failure with stack lines rebased past the preamble', async () => {
const { user, actor } = await makeUser();
const path = `/${user.username}/worker.js`;
@@ -920,7 +998,11 @@ describe('WorkerDriver operator teardown (INTERNAL_ADMIN_DESTROY)', () => {
invalid: true,
});
expect(seen).toEqual([
{ subdomain: `workers.puter.${name}`, uid: row.uuid },
{
subdomain: `workers.puter.${name}`,
uid: row.uuid,
userId: user.id,
},
]);
// The driver's own listener hears this and leaves the script alone.
@@ -1122,7 +1204,11 @@ describe('WorkerDriver operator teardown (INTERNAL_ADMIN_DESTROY)', () => {
await server.stores.subdomain.deleteByUuid(second.uuid);
server.clients.event.emit(
'subdomain.delete',
{ subdomain: `workers.puter.${name}`, uid: second.uuid },
{
subdomain: `workers.puter.${name}`,
uid: second.uuid,
userId: Number(second.user_id),
},
{},
);
@@ -1642,7 +1728,11 @@ describe('WorkerDriver keeps a worker`s token tied to the worker', () => {
await server.stores.subdomain.deleteByUuid(String(row!.uuid));
server.clients.event.emit(
'subdomain.delete',
{ subdomain: `workers.puter.${name}`, uid: String(row!.uuid) },
{
subdomain: `workers.puter.${name}`,
uid: String(row!.uuid),
userId: user.id,
},
{},
);
@@ -0,0 +1,102 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import type { Actor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { INTERNAL_DEPLOY_TARGET, type WorkerDriver } from './WorkerDriver.js';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
describe('dynamic worker token over HTTP', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv({
workers: { localServer: true },
} as never);
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const call = (token: string) =>
fetch(new URL('/drivers/call', env.apiOrigin), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
interface: 'puter-kvstore',
method: 'set',
args: { key: 'visits', value: 1 },
}),
});
const whoami = (token: string) =>
fetch(new URL('/whoami', env.apiOrigin), {
headers: { Authorization: `Bearer ${token}` },
});
it('authenticates a site-bound worker and retires it when the site is deleted', async () => {
const server = env.server;
const { username } = env.users.user;
const user = (await server.stores.user.getByUsername(username))!;
const actor: Actor = {
user: { ...user, email_confirmed: true } as Actor['user'],
};
const origin = 'https://verifysite.puter.site';
const appUid = await server.services.auth.appUidFromOrigin(origin);
await server.stores.app.createFromOrigin(appUid, origin, {
ownerUserId: user.id,
});
const site = await server.stores.subdomain.create({
userId: user.id,
subdomain: 'verifysite',
});
const path = `/${username}/api.worker.js`;
// Valid JS: where workerd is installed, the deploy really starts it.
const source = Buffer.from('// noop\n');
await runWithContext({ actor }, () =>
server.services.fs.write(user.id, {
fileMetadata: { path, size: source.length, overwrite: true },
fileContent: source,
}),
);
const spy = vi.spyOn(server.services.auth, 'createWorkerAppToken');
const scriptName = 'b'.repeat(48);
try {
await runWithContext({ actor }, () =>
(server.drivers.workers as unknown as WorkerDriver).create({
appId: appUid,
workerName: scriptName,
filePath: path,
[INTERNAL_DEPLOY_TARGET]: {
scriptName,
siteUid: site.uuid,
siteSubdomain: site.subdomain,
namespace: 'dynamic',
tags: ['t'],
},
} as never),
);
} catch (e) {
// No preamble built here; the token is minted before deploy.
if ((e as { statusCode?: number }).statusCode !== 503) throw e;
}
const token = (await spy.mock.results.at(-1)!.value) as string;
spy.mockRestore();
const claims = JSON.parse(
Buffer.from(token.split('.')[1], 'base64url').toString(),
);
expect(claims.worker).toBe(true);
expect(claims.worker_name).toBe(scriptName);
expect(claims.worker_uid).toBeUndefined();
expect(claims.workerSiteUid).toBe(site.uuid);
expect(claims.workerSite).toBe(site.subdomain);
expect((await whoami(token)).status).toBe(200);
expect((await call(token)).status).toBe(200);
await server.stores.subdomain.deleteByUuid(site.uuid);
expect((await whoami(token)).status).toBe(401);
expect((await call(token)).status).toBe(401);
});
});
@@ -0,0 +1,191 @@
import { v4 as uuidv4 } from 'uuid';
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from 'vitest';
import type { Actor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { WORKER_SUBDOMAIN_PREFIX } from '../../stores/subdomain/SubdomainStore.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import type { WorkerDriver } from './WorkerDriver.js';
let server: PuterServer;
let target: WorkerDriver;
beforeAll(async () => {
// Local runtime: hot reload subscribes, and deploys go through
// `localworkerservice.cfDeployLocal`, which the test controls.
server = await setupTestServer({
workers: { localServer: true },
} as never);
target = server.drivers.workers as unknown as WorkerDriver;
});
afterAll(async () => {
await server?.shutdown();
});
afterEach(() => {
vi.restoreAllMocks();
});
const makeUser = async () => {
const username = `hr${Math.random().toString(36).slice(2, 10)}`;
const created = await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
free_storage: 50 * 1024 * 1024,
requires_email_confirmation: false,
});
await generateDefaultFsentries(
server.clients.db,
server.stores.user,
created,
);
const user = (await server.stores.user.getById(created.id))!;
const actor: Actor = {
user: {
id: user.id,
uuid: user.uuid,
username: user.username,
email: user.email ?? null,
email_confirmed: true,
} as Actor['user'],
};
return { user, actor };
};
const write = (actor: Actor, userId: number, path: string, body: string) =>
runWithContext({ actor }, () =>
server.services.fs.write(userId, {
fileMetadata: {
path,
size: Buffer.byteLength(body),
contentType: 'application/javascript',
overwrite: true,
},
fileContent: Buffer.from(body),
}),
);
const waitFor = async (predicate: () => boolean, label: string) => {
for (let i = 0; i < 300; i++) {
if (predicate()) return;
await new Promise((r) => setTimeout(r, 10));
}
throw new Error(`timed out waiting for ${label}`);
};
describe('worker hot reload ordering', () => {
it('processes a queued save after the previous source lookup fails', async () => {
const { user, actor } = await makeUser();
const path = `/${user.username}/retry.js`;
await write(actor, user.id, path, 'v1');
const name = `retry${Math.random().toString(36).slice(2, 8)}`;
try {
await runWithContext({ actor }, () =>
target.create({ appId: '', workerName: name, filePath: path }),
);
} catch (error) {
if ((error as { statusCode?: number }).statusCode !== 503)
throw error;
}
let lookupStarted = false;
let failLookup!: (error: Error) => void;
const pendingLookup = new Promise<never>((_resolve, reject) => {
failLookup = reject;
});
const pread = server.clients.db.pread.bind(server.clients.db);
vi.spyOn(server.clients.db, 'pread').mockImplementation(
(query, params) => {
if (
!lookupStarted &&
query.includes('FROM `subdomains` WHERE `subdomain` IN') &&
params?.includes(`${WORKER_SUBDOMAIN_PREFIX}${name}`)
) {
lookupStarted = true;
return pendingLookup;
}
return pread(query, params);
},
);
let live: string | null = null;
vi.spyOn(
server.services.localworkerservice,
'cfDeployLocal',
).mockImplementation(
async (_name: string, _auth: unknown, code: string) => {
live = code;
return { success: true, errors: [], url: null } as never;
},
);
await write(actor, user.id, path, 'v2');
await waitFor(() => lookupStarted, 'source lookup to start');
await write(actor, user.id, path, 'v3');
await new Promise((resolve) => setImmediate(resolve));
failLookup(new Error('temporary database outage'));
await waitFor(
() => live?.endsWith('v3') === true,
'queued source to deploy',
);
});
it('coalesces rapid saves and deploys the latest source after the pending deploy', async () => {
const { user, actor } = await makeUser();
const path = `/${user.username}/order.js`;
await write(actor, user.id, path, 'v1');
const name = `order${Math.random().toString(36).slice(2, 8)}`;
try {
await runWithContext({ actor }, () =>
target.create({ appId: '', workerName: name, filePath: path }),
);
} catch (e) {
// No preamble in this tree: the initial deploy answers 503, after
// the worker row is written. Hot reload doesn't need it.
if ((e as { statusCode?: number }).statusCode !== 503) throw e;
}
// Records what is live, in the order deploys complete.
let live: string | null = null;
let started = 0;
let releaseFirst!: () => void;
const firstGate = new Promise<void>((r) => (releaseFirst = r));
vi.spyOn(
server.services.localworkerservice,
'cfDeployLocal',
).mockImplementation(
async (_name: string, _auth: unknown, code: string) => {
const call = ++started;
if (call === 1) await firstGate;
live = code;
return { success: true, errors: [], url: null } as never;
},
);
await write(actor, user.id, path, 'v2');
await waitFor(() => started === 1, 'first redeploy to start');
await write(actor, user.id, path, 'v3');
await new Promise((resolve) => setTimeout(resolve, 50));
expect(started).toBe(1);
releaseFirst();
await waitFor(
() => started === 2 && live?.endsWith('v3') === true,
'latest source to finish',
);
const stat = await server.stores.fsEntry.getEntryByPath(path);
expect(stat).toBeTruthy();
expect(live!.endsWith('v3')).toBe(true);
});
});
+69 -17
View File
@@ -148,6 +148,10 @@ const withDeadline = <T>(promise: Promise<T>, ms: number): Promise<T> => {
export interface InternalDeployTarget {
/** Script name to deploy under, in place of `workerName`. */
scriptName: string;
/** Site row that owns the deployed worker credential. */
siteUid?: string;
/** That site's subdomain name; required alongside `siteUid`. */
siteSubdomain?: string;
/** Grouping to deploy into, in place of the configured default. */
namespace?: string;
/**
@@ -182,7 +186,6 @@ const MAX_SOURCE_SIZE = 10 * 1024 * 1024; // 10 MB
// see. A user is capped at MAX_WORKERS_PER_USER workers, so only that many
// child apps can actually own one; the ceiling is generous slack over that.
const CHILD_APP_SCAN_LIMIT = 1000;
let USE_LOCAL_WORKERD = false;
// -- Preamble --------------------------------------------------------
//
@@ -341,7 +344,12 @@ export class WorkerDriver extends PuterDriver {
};
#cfBaseUrl = '';
#useLocalWorkerd = false;
#hotReloadSubscribed = false;
#sourceReloads = new Map<
string,
{ latest: FSEntry | null; promise: Promise<void> }
>();
// Row uids this process tore down itself; its own announcement skips them.
#tornDown = new BoundedTtlMap<string, true>({
maxEntries: 1000,
@@ -375,7 +383,7 @@ export class WorkerDriver extends PuterDriver {
);
}
} else if (cfg.localServer) {
USE_LOCAL_WORKERD = true;
this.#useLocalWorkerd = true;
}
this.#subscribeHotReload();
}
@@ -484,11 +492,7 @@ export class WorkerDriver extends PuterDriver {
: null;
// A worker is redeployed from its file as the worker's owner, so a
// file someone else owns would let them swap the code it runs.
if (
!deployTarget &&
loaded?.fsEntry &&
loaded.fsEntry.userId !== actor.user.id
) {
if (loaded?.fsEntry && loaded.fsEntry.userId !== actor.user.id) {
throw new HttpError(
403,
'A worker can only be deployed from a file you own',
@@ -516,7 +520,11 @@ export class WorkerDriver extends PuterDriver {
actor,
boundApp.uid,
workerName,
{ workerUid },
{
workerUid,
workerSiteUid: deployTarget?.siteUid,
workerSite: deployTarget?.siteSubdomain,
},
);
}
if (!authorization && !omitOwnerToken) {
@@ -533,7 +541,11 @@ export class WorkerDriver extends PuterDriver {
userRow,
workerName,
{},
{ workerUid },
{
workerUid,
workerSiteUid: deployTarget?.siteUid,
workerSite: deployTarget?.siteSubdomain,
},
);
authorization = session.token;
}
@@ -696,13 +708,16 @@ export class WorkerDriver extends PuterDriver {
const subdomain = String(row.subdomain);
const workerName = subdomain.slice(WORKER_SUBDOMAIN_PREFIX.length);
await this.#takeDownScript(workerName);
await this.services.auth.deleteWorkerSessionsByName(workerName);
await this.services.auth.deleteWorkerSessionsByName(
workerName,
Number(row.user_id),
);
await this.stores.subdomain.deleteByUuid(row.uuid, { userId });
this.#tornDown.set(row.uuid, true);
try {
this.clients.event.emit(
'subdomain.delete',
{ subdomain, uid: row.uuid },
{ subdomain, uid: row.uuid, userId: Number(row.user_id) },
{},
);
} catch {
@@ -860,7 +875,7 @@ export class WorkerDriver extends PuterDriver {
target?: InternalDeployTarget,
): Promise<Record<string, unknown>> {
const secrets = Object.entries(target?.secrets ?? {});
if (USE_LOCAL_WORKERD) {
if (this.#useLocalWorkerd) {
// Events workers get their own registry key so they can never
// resolve on the public local-worker host or collide with an
// ordinary worker's name — see `cfCallLocal`.
@@ -967,7 +982,7 @@ export class WorkerDriver extends PuterDriver {
/** Edge delete: success or code 10007 resolves, anything else refuses. */
async #cfDelete(workerName: string): Promise<Record<string, unknown>> {
if (USE_LOCAL_WORKERD) {
if (this.#useLocalWorkerd) {
return withDeadline(
this.services.localworkerservice.cfDeleteLocal(workerName),
CF_DELETE_TIMEOUT_MS,
@@ -1219,7 +1234,7 @@ export class WorkerDriver extends PuterDriver {
// while worker subdomains are keyed to the numeric fsentries.id.
#subscribeHotReload(): void {
if (!this.#cfBaseUrl && !USE_LOCAL_WORKERD) return;
if (!this.#cfBaseUrl && !this.#useLocalWorkerd) return;
// Idempotent: re-entry (e.g. a second onServerStart) must not stack
// duplicate listeners — each duplicate would multiply redeploys on
// every user's worker-source save.
@@ -1254,7 +1269,10 @@ export class WorkerDriver extends PuterDriver {
// owner's account) still has a deployed script to take down.
this.clients.event.on(
'subdomain.delete',
(_key: string, data: { subdomain?: unknown; uid?: unknown }) => {
(
_key: string,
data: { subdomain?: unknown; uid?: unknown; userId?: number },
) => {
const subdomain = String(data?.subdomain ?? '');
if (!subdomain.startsWith(WORKER_SUBDOMAIN_PREFIX)) return;
// Its own announcement: that row is already fully torn down.
@@ -1273,7 +1291,7 @@ export class WorkerDriver extends PuterDriver {
);
});
void this.services.auth
.deleteWorkerSessionsByName(workerName)
.deleteWorkerSessionsByName(workerName, Number(data.userId))
.catch((err) => {
console.warn(
`[workers] session cleanup failed for ${workerName}`,
@@ -1298,6 +1316,37 @@ export class WorkerDriver extends PuterDriver {
const entry = this.#extractFsEntryFromEvent(data);
if (!entry || entry.isDir) return;
if (!entry.workers?.length) return;
const running = this.#sourceReloads.get(entry.uuid);
if (running) {
running.latest = entry;
return running.promise;
}
const state = {
latest: entry as FSEntry | null,
promise: Promise.resolve(),
};
this.#sourceReloads.set(entry.uuid, state);
state.promise = (async () => {
try {
while (state.latest) {
const latest = state.latest;
state.latest = null;
try {
await this.#deploySource(latest);
} catch (error) {
if (!state.latest) throw error;
console.error('[workers] hot-reload error', error);
}
}
} finally {
this.#sourceReloads.delete(entry.uuid);
}
})();
return state.promise;
}
async #deploySource(entry: FSEntry): Promise<void> {
const matched = await this.#listWorkerRowsForEntry(entry);
if (matched.length === 0) return;
@@ -1540,7 +1589,10 @@ export class WorkerDriver extends PuterDriver {
userId,
});
}
await this.services.auth.deleteWorkerSessionsByName(workerName);
await this.services.auth.deleteWorkerSessionsByName(
workerName,
Number(row.user_id),
);
} catch (err) {
console.warn(
`[workers] source cleanup failed for ${workerName}`,
+149 -1
View File
@@ -1933,6 +1933,97 @@ describe('AuthService (integration)', () => {
expect((await authService.authenticate(token)).actor).toBeTruthy();
});
describe('bound to the site that deployed it', () => {
const makeSite = async (
userId: number,
subdomain = `site-${uuidv4()}`,
) => await server.stores.subdomain.create({ userId, subdomain });
const mint = async (
user: Awaited<ReturnType<typeof makeUser>>,
site: { uuid?: string; subdomain?: string },
) =>
(
await authService.createWorkerSessionToken(
makeActor({ user }),
user,
'd'.repeat(48),
{},
{
workerSiteUid: site.uuid,
workerSite: site.subdomain,
},
)
).token;
it('authenticates from the subdomain cache, never the primary', async () => {
const user = await makeUser();
const site = await makeSite(user.id);
const token = await mint(user, site);
expect(
(await authService.authenticate(token)).actor?.user?.id,
).toBe(user.id);
const pread = vi.spyOn(server.clients.db, 'pread');
const byUuid = vi.spyOn(server.stores.subdomain, 'getByUuid');
try {
expect(
(await authService.authenticate(token)).actor?.user?.id,
).toBe(user.id);
expect(
pread.mock.calls.filter(([sql]) =>
String(sql).includes('subdomains'),
),
).toEqual([]);
expect(byUuid).not.toHaveBeenCalled();
} finally {
pread.mockRestore();
byUuid.mockRestore();
}
});
it('dies with the site and stays dead when the name is re-created', async () => {
const user = await makeUser();
const other = await makeUser();
const site = await makeSite(user.id);
const token = await mint(user, site);
await server.stores.subdomain.deleteByUuid(String(site.uuid));
expect(await authService.authenticate(token)).toEqual({
invalid: true,
});
const again = await makeSite(user.id, site.subdomain);
expect(await authService.authenticate(token)).toEqual({
invalid: true,
});
await server.stores.subdomain.deleteByUuid(String(again.uuid));
await makeSite(other.id, site.subdomain);
expect(await authService.authenticate(token)).toEqual({
invalid: true,
});
});
it("refuses another user's site, a worker row, a mismatched name, or no name", async () => {
const user = await makeUser();
const other = await makeUser();
const own = await makeSite(user.id);
const foreign = await makeSite(other.id);
const workerRow = await deployRow(user.id, newName());
for (const site of [
foreign,
workerRow,
{ uuid: own.uuid, subdomain: foreign.subdomain },
{ uuid: own.uuid },
]) {
expect(
await authService.authenticate(await mint(user, site)),
).toEqual({
invalid: true,
});
}
});
});
it('leaves events handler sessions alone; they have no worker row', async () => {
const user = await makeUser();
const app = await server.stores.app.create(
@@ -1953,6 +2044,63 @@ describe('AuthService (integration)', () => {
});
describe('deleting worker sessions', () => {
it("keeps another owner's session when a script name is shared", async () => {
const first = await makeUser();
const second = await makeUser();
const workerName = `shared-${uuidv4().slice(0, 8)}`;
const firstSite = await server.stores.subdomain.create({
userId: first.id,
subdomain: `site-${uuidv4()}`,
});
const secondSite = await server.stores.subdomain.create({
userId: second.id,
subdomain: `site-${uuidv4()}`,
});
const one = await authService.createWorkerSessionToken(
makeActor({ user: first }),
first,
workerName,
{},
{
workerSiteUid: firstSite.uuid,
workerSite: firstSite.subdomain,
},
);
const two = await authService.createWorkerSessionToken(
makeActor({ user: second }),
second,
workerName,
{},
{
workerSiteUid: secondSite.uuid,
workerSite: secondSite.subdomain,
},
);
expect(
(await authService.authenticate(two.token)).actor?.user?.id,
).toBe(second.id);
await authService.deleteWorkerSessionsByName(workerName, first.id);
expect(await authService.authenticate(one.token)).toEqual({
invalid: true,
});
expect(
(await authService.authenticate(two.token)).actor?.user?.id,
).toBe(second.id);
const wrongSite = await authService.createWorkerSessionToken(
makeActor({ user: first }),
first,
workerName,
{},
{
workerSiteUid: secondSite.uuid,
workerSite: secondSite.subdomain,
},
);
expect(await authService.authenticate(wrongSite.token)).toEqual({
invalid: true,
});
});
it('deleteWorkerSessionsByName ends the token and drops a derived token`s grants', async () => {
const user = await makeUser();
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
@@ -1980,7 +2128,7 @@ describe('AuthService (integration)', () => {
[childUid, user.id, 'fs:x:read'],
);
await authService.deleteWorkerSessionsByName(workerName);
await authService.deleteWorkerSessionsByName(workerName, user.id);
// The worker row is still there: only the session went.
expect(await authService.authenticate(token)).toEqual({
+44 -10
View File
@@ -414,6 +414,8 @@ export class AuthService extends PuterService {
worker?: boolean;
workerName?: string;
workerUid?: string;
workerSiteUid?: string;
workerSite?: string;
} = {},
): string {
const claims: Record<string, unknown> = {
@@ -430,6 +432,8 @@ export class AuthService extends PuterService {
if (opts.worker) claims.worker = true;
if (opts.workerName) claims.worker_name = opts.workerName;
if (opts.workerUid) claims.worker_uid = opts.workerUid;
if (opts.workerSiteUid) claims.workerSiteUid = opts.workerSiteUid;
if (opts.workerSite) claims.workerSite = opts.workerSite;
return this.services.token.sign('auth', claims);
}
@@ -448,7 +452,15 @@ export class AuthService extends PuterService {
user: UserRow,
workerName: string,
meta: Record<string, unknown> = {},
{ workerUid }: { workerUid?: string } = {},
{
workerUid,
workerSiteUid,
workerSite,
}: {
workerUid?: string;
workerSiteUid?: string;
workerSite?: string;
} = {},
): Promise<{
session: Record<string, unknown>;
token: string;
@@ -480,14 +492,14 @@ export class AuthService extends PuterService {
user,
session.uuid as string,
auth_id,
{ worker: true, workerName, workerUid },
{ worker: true, workerName, workerUid, workerSiteUid, workerSite },
);
const gui_token = this.#signSessionTypeToken(
'gui',
user,
session.uuid as string,
auth_id,
{ worker: true, workerName, workerUid },
{ worker: true, workerName, workerUid, workerSiteUid, workerSite },
);
return { session, token, gui_token };
@@ -516,6 +528,8 @@ export class AuthService extends PuterService {
handlerDepth?: number;
expiresInSeconds?: number;
workerUid?: string;
workerSiteUid?: string;
workerSite?: string;
} = {},
): Promise<string> {
if (!actor.user) {
@@ -568,6 +582,12 @@ export class AuthService extends PuterService {
worker: true,
worker_name: workerName,
...(options.workerUid ? { worker_uid: options.workerUid } : {}),
...(options.workerSiteUid
? { workerSiteUid: options.workerSiteUid }
: {}),
...(options.workerSite
? { workerSite: options.workerSite }
: {}),
...(options.handlerDepth
? { handler_depth: options.handlerDepth }
: {}),
@@ -932,13 +952,13 @@ export class AuthService extends PuterService {
for (const entry of revoked) this.#announceRevocation(entry);
}
/**
* Delete every session of a worker that no longer exists, whoever minted
* it: worker names are unique, so nothing else can be using them.
*/
async deleteWorkerSessionsByName(workerName: string): Promise<void> {
if (!workerName) return;
await this.#deleteWorkerSessions({ workerName });
/** Delete the owner's sessions for a worker that no longer exists. */
async deleteWorkerSessionsByName(
workerName: string,
userId: number,
): Promise<void> {
if (!workerName || !userId) return;
await this.#deleteWorkerSessions({ workerName, userId });
}
/**
@@ -2519,6 +2539,8 @@ export class AuthService extends PuterService {
worker?: boolean;
worker_name?: string;
worker_uid?: string;
workerSiteUid?: string;
workerSite?: string;
},
userId: number,
): Promise<boolean> {
@@ -2526,6 +2548,18 @@ export class AuthService extends PuterService {
// Events handler sessions have no worker row; EventsService retires them.
if (decoded.worker_name === EVENTS_WORKER_SESSION_NAME) return true;
if (!decoded.worker_name) return false;
if (decoded.workerSiteUid) {
// Looked up by name to hit the subdomain cache; the uid pins the
// row, so a deleted or re-created site fails.
const name = decoded.workerSite;
if (!name || name.startsWith(WORKER_SUBDOMAIN_PREFIX)) return false;
const site = await this.stores.subdomain.getBySubdomain(name);
return (
!!site &&
site.uuid === decoded.workerSiteUid &&
Number(site.user_id) === Number(userId)
);
}
const row = await this.stores.subdomain.getBySubdomain(
`${WORKER_SUBDOMAIN_PREFIX}${decoded.worker_name}`,
);
+8
View File
@@ -56,6 +56,10 @@ export interface SessionTokenPayload extends TokenPayloadBase {
worker_name?: string;
/** Uuid of the worker's subdomain row; the token dies with that row. */
worker_uid?: string;
/** Site row that owns a dynamically deployed worker. */
workerSiteUid?: string;
/** That site's subdomain name, for the cached lookup. */
workerSite?: string;
}
/**
@@ -74,6 +78,10 @@ export interface AppUnderUserTokenPayload extends TokenPayloadBase {
worker_name?: string;
/** Uuid of the worker's subdomain row; the token dies with that row. */
worker_uid?: string;
/** Site row that owns a dynamically deployed worker. */
workerSiteUid?: string;
/** That site's subdomain name, for the cached lookup. */
workerSite?: string;
}
/**
@@ -297,8 +297,16 @@ describe('UserAccountService', () => {
);
expect(deletes.map((e) => e.data)).toEqual(
expect.arrayContaining([
{ subdomain: site.subdomain, uid: site.uuid },
{ subdomain: worker.subdomain, uid: worker.uuid },
{
subdomain: site.subdomain,
uid: site.uuid,
userId: user.id,
},
{
subdomain: worker.subdomain,
uid: worker.uuid,
userId: user.id,
},
]),
);
// Listeners keyed on the user still see the sites go first.
@@ -222,7 +222,11 @@ export class UserAccountService extends PuterService {
try {
this.clients.event.emit(
'subdomain.delete',
{ subdomain: row.subdomain, uid: String(row.uuid) },
{
subdomain: row.subdomain,
uid: String(row.uuid),
userId,
},
{},
);
} catch {
@@ -562,11 +562,13 @@ describe('SubdomainStore app_owner filtering', () => {
uuid: owned.uuid,
subdomain: owned.subdomain,
root_dir_id: null,
user_id: siteOwner,
},
{
uuid: associated.uuid,
subdomain: associated.subdomain,
root_dir_id: 7,
user_id: siteOwner,
},
]),
);
@@ -612,8 +614,16 @@ describe('SubdomainStore app_owner filtering', () => {
expect(events).toEqual(
expect.arrayContaining([
{ subdomain: owned.subdomain, uid: owned.uuid },
{ subdomain: associated.subdomain, uid: associated.uuid },
{
subdomain: owned.subdomain,
uid: owned.uuid,
userId: siteOwner,
},
{
subdomain: associated.subdomain,
uid: associated.uuid,
userId: siteOwner,
},
]),
);
// SQLite reuses the deleted app's id; drop anything it left behind.
@@ -49,7 +49,7 @@ export interface SubdomainRow {
/** A row deleted by the database along with an app. */
export type CascadeRow = Pick<
SubdomainRow,
'uuid' | 'subdomain' | 'root_dir_id'
'uuid' | 'subdomain' | 'root_dir_id' | 'user_id'
>;
// Columns that may not be set through an `update` patch map. Defence-in-depth
@@ -813,13 +813,13 @@ export class SubdomainStore extends PuterStore {
let afterId = 0;
for (;;) {
const rows = (await this.clients.db.pread(
`SELECT \`id\`, \`uuid\`, \`subdomain\`, \`root_dir_id\` FROM \`subdomains\`
`SELECT \`id\`, \`uuid\`, \`subdomain\`, \`root_dir_id\`, \`user_id\` FROM \`subdomains\`
WHERE (\`app_owner\` ${match} OR \`associated_app_id\` ${match})
AND \`id\` > ? ORDER BY \`id\` LIMIT ?`,
[param, param, afterId, BULK_QUERY_CHUNK_SIZE],
)) as Array<Pick<SubdomainRow, 'id'> & CascadeRow>;
for (const { uuid, subdomain, root_dir_id } of rows) {
out.push({ uuid, subdomain, root_dir_id });
for (const { uuid, subdomain, root_dir_id, user_id } of rows) {
out.push({ uuid, subdomain, root_dir_id, user_id });
}
if (rows.length < BULK_QUERY_CHUNK_SIZE) return out;
afterId = Number(rows[rows.length - 1].id);
@@ -849,7 +849,11 @@ export class SubdomainStore extends PuterStore {
try {
this.clients.event.emit(
'subdomain.delete',
{ subdomain: row.subdomain, uid: String(row.uuid) },
{
subdomain: row.subdomain,
uid: String(row.uuid),
userId: Number(row.user_id),
},
{},
);
} catch {