From 28be1851f9318d7b2cb5d0faebef9c6935d37326 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Wed, 28 Oct 2026 17:31:15 -0700 Subject: [PATCH] fix(workers): bind credentials and cleanup to the owning site (#4362) * 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. --- src/backend/clients/event/types.ts | 2 +- .../hosting/HostingController.test.js | 2 +- .../drivers/subdomain/SubdomainDriver.ts | 1 + .../WorkerDriver.cascadeSession.test.ts | 107 ++++++++++ .../workers/WorkerDriver.cloudflare.test.ts | 96 ++++++++- .../WorkerDriver.dynamicToken.http.test.ts | 102 ++++++++++ .../WorkerDriver.reloadOrdering.test.ts | 191 ++++++++++++++++++ src/backend/drivers/workers/WorkerDriver.ts | 86 ++++++-- src/backend/services/auth/AuthService.test.ts | 150 +++++++++++++- src/backend/services/auth/AuthService.ts | 54 ++++- src/backend/services/auth/types.ts | 8 + .../services/user/UserAccountService.test.ts | 12 +- .../services/user/UserAccountService.ts | 6 +- .../stores/subdomain/SubdomainStore.test.ts | 14 +- .../stores/subdomain/SubdomainStore.ts | 14 +- 15 files changed, 802 insertions(+), 43 deletions(-) create mode 100644 src/backend/drivers/workers/WorkerDriver.cascadeSession.test.ts create mode 100644 src/backend/drivers/workers/WorkerDriver.dynamicToken.http.test.ts create mode 100644 src/backend/drivers/workers/WorkerDriver.reloadOrdering.test.ts diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index cb3742787..8b5c78459 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -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; diff --git a/src/backend/controllers/hosting/HostingController.test.js b/src/backend/controllers/hosting/HostingController.test.js index 5deee5830..f0bd4441f 100644 --- a/src/backend/controllers/hosting/HostingController.test.js +++ b/src/backend/controllers/hosting/HostingController.test.js @@ -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 { diff --git a/src/backend/drivers/subdomain/SubdomainDriver.ts b/src/backend/drivers/subdomain/SubdomainDriver.ts index 94894c4cb..18b1ec292 100644 --- a/src/backend/drivers/subdomain/SubdomainDriver.ts +++ b/src/backend/drivers/subdomain/SubdomainDriver.ts @@ -452,6 +452,7 @@ export class SubdomainDriver extends PuterDriver { { subdomain: row.subdomain as string, uid: String(row.uuid), + userId: row.user_id as number, }, {}, ); diff --git a/src/backend/drivers/workers/WorkerDriver.cascadeSession.test.ts b/src/backend/drivers/workers/WorkerDriver.cascadeSession.test.ts new file mode 100644 index 000000000..6f2780508 --- /dev/null +++ b/src/backend/drivers/workers/WorkerDriver.cascadeSession.test.ts @@ -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], + ); + }); +}); diff --git a/src/backend/drivers/workers/WorkerDriver.cloudflare.test.ts b/src/backend/drivers/workers/WorkerDriver.cloudflare.test.ts index aff7a8ebb..5fb017033 100644 --- a/src/backend/drivers/workers/WorkerDriver.cloudflare.test.ts +++ b/src/backend/drivers/workers/WorkerDriver.cloudflare.test.ts @@ -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, + }, {}, ); diff --git a/src/backend/drivers/workers/WorkerDriver.dynamicToken.http.test.ts b/src/backend/drivers/workers/WorkerDriver.dynamicToken.http.test.ts new file mode 100644 index 000000000..2b7cdeba3 --- /dev/null +++ b/src/backend/drivers/workers/WorkerDriver.dynamicToken.http.test.ts @@ -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); + }); +}); diff --git a/src/backend/drivers/workers/WorkerDriver.reloadOrdering.test.ts b/src/backend/drivers/workers/WorkerDriver.reloadOrdering.test.ts new file mode 100644 index 000000000..ed7f1fd97 --- /dev/null +++ b/src/backend/drivers/workers/WorkerDriver.reloadOrdering.test.ts @@ -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((_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((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); + }); +}); diff --git a/src/backend/drivers/workers/WorkerDriver.ts b/src/backend/drivers/workers/WorkerDriver.ts index decc0307d..04741e670 100644 --- a/src/backend/drivers/workers/WorkerDriver.ts +++ b/src/backend/drivers/workers/WorkerDriver.ts @@ -148,6 +148,10 @@ const withDeadline = (promise: Promise, ms: number): Promise => { 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 } + >(); // Row uids this process tore down itself; its own announcement skips them. #tornDown = new BoundedTtlMap({ 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> { 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> { - 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 { 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}`, diff --git a/src/backend/services/auth/AuthService.test.ts b/src/backend/services/auth/AuthService.test.ts index c151ebdab..a405766c0 100644 --- a/src/backend/services/auth/AuthService.test.ts +++ b/src/backend/services/auth/AuthService.test.ts @@ -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>, + 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({ diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts index c2e85c3b2..a919e6af9 100644 --- a/src/backend/services/auth/AuthService.ts +++ b/src/backend/services/auth/AuthService.ts @@ -414,6 +414,8 @@ export class AuthService extends PuterService { worker?: boolean; workerName?: string; workerUid?: string; + workerSiteUid?: string; + workerSite?: string; } = {}, ): string { const claims: Record = { @@ -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 = {}, - { workerUid }: { workerUid?: string } = {}, + { + workerUid, + workerSiteUid, + workerSite, + }: { + workerUid?: string; + workerSiteUid?: string; + workerSite?: string; + } = {}, ): Promise<{ session: Record; 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 { 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 { - 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 { + 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 { @@ -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}`, ); diff --git a/src/backend/services/auth/types.ts b/src/backend/services/auth/types.ts index bc7f42a79..8211b89b5 100644 --- a/src/backend/services/auth/types.ts +++ b/src/backend/services/auth/types.ts @@ -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; } /** diff --git a/src/backend/services/user/UserAccountService.test.ts b/src/backend/services/user/UserAccountService.test.ts index 68bce474b..fca6e9eb9 100644 --- a/src/backend/services/user/UserAccountService.test.ts +++ b/src/backend/services/user/UserAccountService.test.ts @@ -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. diff --git a/src/backend/services/user/UserAccountService.ts b/src/backend/services/user/UserAccountService.ts index abaf63b5c..9fbf6b40a 100644 --- a/src/backend/services/user/UserAccountService.ts +++ b/src/backend/services/user/UserAccountService.ts @@ -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 { diff --git a/src/backend/stores/subdomain/SubdomainStore.test.ts b/src/backend/stores/subdomain/SubdomainStore.test.ts index 3ed51f92c..eee096384 100644 --- a/src/backend/stores/subdomain/SubdomainStore.test.ts +++ b/src/backend/stores/subdomain/SubdomainStore.test.ts @@ -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. diff --git a/src/backend/stores/subdomain/SubdomainStore.ts b/src/backend/stores/subdomain/SubdomainStore.ts index ce5018aa5..d09fd6d37 100644 --- a/src/backend/stores/subdomain/SubdomainStore.ts +++ b/src/backend/stores/subdomain/SubdomainStore.ts @@ -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 & 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 {