mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-04 04:53:39 +00:00
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
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:
1 parent
b19fde242f
commit
28be1851f9
15 files changed
+802
-43
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user