refactor(drivers): shared actor guards and crud-q concurrency policy

The apps, subdomains, workers and notifications drivers each carried a
private #requireActor, two carried the same #requireVerified, and apps and
subdomains the same owner/app_owner write predicate. These become
requireContextActor, requireContextUserActor, assertActorEmailVerified and
actorOwnsRow in core/actor.ts; each driver keeps its own error for the
cases that differed (system actors still read apps; notifications still
answer 403 for an actor without a user). The identical crud-q concurrency
block is one CRUD_CONCURRENT constant.

The metering, installedApps and appTelemetry extensions re-checked an actor
their routes already require; those checks and the tests for them go.
This commit is contained in:
Daniel Salazar committed 2026-10-10 19:37:12 -04:00
1 parent d0100a5c66
commit 5008da6fed
12 files changed
+293 -225

No files matched your search

+11 -6
View File
@@ -1,5 +1,4 @@
import { Context } from '@heyputer/backend/src/core';
import type { Actor } from '@heyputer/backend/src/core/actor';
import { HttpError } from '@heyputer/backend/src/core/http';
import { PuterDriver } from '@heyputer/backend/src/drivers/types';
import type {
@@ -133,11 +132,17 @@ export class AppTelemetryDriver extends PuterDriver {
const owner = await this.stores.user.getById(ownerId);
if (!owner?.uuid) throw new HttpError(404, 'App owner not found');
const actor = Context.get('actor');
if (!actor) throw new HttpError(401, 'Authentication required');
const ownsApp = await this.services.permission
.check(actor as Actor, `apps-of-user:${owner.uuid}:write`)
.catch(() => false);
// `/drivers/call` requires auth, so there is always an actor.
const actor = Context.get('actor')!;
let ownsApp = false;
try {
ownsApp = await this.services.permission.check(
actor,
`apps-of-user:${owner.uuid}:write`,
);
} catch {
// A failed check is a denial.
}
if (!ownsApp) throw new HttpError(403, 'Permission denied');
const appId = (app as { id: number }).id;
-9
View File
@@ -74,15 +74,6 @@ const grantInstalled = async (appId: number, userId: number) => {
};
describe('installedApps extension — handleInstalledApps', () => {
it('throws HttpError(401) when no actor is on the context', async () => {
const { res } = makeRes();
await expect(
runWithContext({ actor: undefined }, () =>
handleInstalledApps(makeReq({}), res),
),
).rejects.toMatchObject({ statusCode: 401 });
});
it('throws HttpError(400) when orderBy is not in the allowlist', async () => {
const user = await seedUser();
const { res } = makeRes();
+2 -2
View File
@@ -28,8 +28,8 @@ export const handleInstalledApps = async (
req: Request,
res: Response,
): Promise<void> => {
const actor = Context.get('actor');
if (!actor?.user?.id) throw new HttpError(401, 'Authentication required');
// Behind `requireUserActor`, so there is always an actor.
const actor = Context.get('actor')!;
const orderBy = String(req.query.orderBy ?? 'installed_at');
if (!(ALLOWED_ORDER_BY as readonly string[]).includes(orderBy)) {
-21
View File
@@ -63,15 +63,6 @@ const seedUser = async () => {
};
describe('metering extension — handleMeteringUsage', () => {
it('throws HttpError(401) when no user actor is on the context', async () => {
const { res } = makeRes();
await expect(
runWithContext({ actor: undefined }, () =>
handleMeteringUsage(makeReq(), res),
),
).rejects.toMatchObject({ statusCode: 401 });
});
it('returns usage details merged with allowanceInfo for an authenticated user', async () => {
const user = await seedUser();
const { res, captured } = makeRes();
@@ -166,18 +157,6 @@ describe('metering extension — handleMeteringUsage', () => {
});
describe('metering extension — handleMeteringUsageForApp', () => {
it('throws HttpError(401) when no user actor is on the context', async () => {
const { res } = makeRes();
await expect(
runWithContext({ actor: undefined }, () =>
handleMeteringUsageForApp(
makeReq({ params: { appIdOrName: 'any' } }),
res,
),
),
).rejects.toMatchObject({ statusCode: 401 });
});
it('throws HttpError(400) when no appId is supplied', async () => {
const user = await seedUser();
const { res } = makeRes();
+4 -4
View File
@@ -95,8 +95,8 @@ export const handleMeteringUsage = async (
_req: Request,
res: Response,
): Promise<void> => {
const actor = Context.get('actor');
if (!actor?.user) throw new HttpError(401, 'Authentication required');
// Behind `requireAuth`, so there is always an actor.
const actor = Context.get('actor')!;
const [actorUsage, allowanceInfo] = await Promise.all([
services.metering.getActorCurrentMonthUsageDetails(actor),
@@ -146,8 +146,8 @@ export const handleMeteringUsageForApp = async (
req: Request,
res: Response,
): Promise<void> => {
const actor = Context.get('actor');
if (!actor?.user) throw new HttpError(401, 'Authentication required');
// Behind `requireAuth`, so there is always an actor.
const actor = Context.get('actor')!;
let appId = String(req.params.appIdOrName ?? '');
if (!appId) throw new HttpError(400, 'appId parameter is required');
+117
View File
@@ -0,0 +1,117 @@
/*
* 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/>.
*/
import { describe, expect, it } from 'vitest';
import {
actorOwnsRow,
assertActorEmailVerified,
makeActor,
requireContextActor,
requireContextUserActor,
SYSTEM_ACTOR,
type Actor,
} from './actor.js';
import { runWithContext } from './context.js';
const user = { id: 7, uuid: 'u-7', username: 'dev', email_confirmed: true };
const plain = makeActor({ user });
const asApp = (appId: number) =>
makeActor({ user, app: { id: appId, uid: `app-${appId}` } });
describe('requireContextActor', () => {
it('returns the request actor and 401s without one', () => {
expect(runWithContext({ actor: plain }, requireContextActor)).toBe(
plain,
);
expect(() =>
runWithContext({ actor: undefined }, requireContextActor),
).toThrow(expect.objectContaining({ statusCode: 401 }));
});
it('accepts an actor without a user id', () => {
expect(
runWithContext({ actor: SYSTEM_ACTOR }, requireContextActor),
).toBe(SYSTEM_ACTOR);
});
});
describe('requireContextUserActor', () => {
it('returns an actor acting for a user', () => {
expect(runWithContext({ actor: plain }, requireContextUserActor)).toBe(
plain,
);
});
it('401s without an actor or without a user id', () => {
for (const actor of [undefined, SYSTEM_ACTOR]) {
expect(() =>
runWithContext({ actor }, requireContextUserActor),
).toThrow(
expect.objectContaining({
statusCode: 401,
legacyCode: 'unauthorized',
}),
);
}
});
});
describe('assertActorEmailVerified', () => {
const unverified: Actor = makeActor({
user: { ...user, email_confirmed: false },
});
it('is inert unless strict verification is configured', () => {
expect(() => assertActorEmailVerified(unverified, {})).not.toThrow();
});
it('rejects an unverified actor with 400 under strict verification', () => {
const strict = { strict_email_verification_required: true };
expect(() => assertActorEmailVerified(plain, strict)).not.toThrow();
expect(() => assertActorEmailVerified(unverified, strict)).toThrow(
expect.objectContaining({
statusCode: 400,
legacyCode: 'account_is_not_verified',
}),
);
});
});
describe('actorOwnsRow', () => {
it('grants the owning user acting as themselves', () => {
expect(actorOwnsRow(plain, { ownerUserId: 7, appOwnerId: 3 })).toBe(
true,
);
expect(actorOwnsRow(plain, { ownerUserId: 8, appOwnerId: null })).toBe(
false,
);
});
it('grants an app actor only the owner’s rows that app created', () => {
expect(actorOwnsRow(asApp(3), { ownerUserId: 7, appOwnerId: 3 })).toBe(
true,
);
expect(actorOwnsRow(asApp(4), { ownerUserId: 7, appOwnerId: 3 })).toBe(
false,
);
expect(actorOwnsRow(asApp(3), { ownerUserId: 8, appOwnerId: 3 })).toBe(
false,
);
});
});
+57
View File
@@ -18,6 +18,9 @@
*/
import { UserRow } from '../stores/user/UserStore';
import { Context } from './context';
import { HttpError } from './http/HttpError';
import { assertVerifiedEmail } from './http/verifiedEmail';
export interface ActorApp {
uid: string;
@@ -116,6 +119,60 @@ export const assertResolvedActor = (actor: Actor): Actor => {
return actor;
};
/** An actor acting for a user, as most driver methods require. */
export type UserActor = Actor & {
user: { id: number; uuid: string; username: string };
};
const authenticationRequired = () =>
new HttpError(401, 'Authentication required', {
legacyCode: 'unauthorized',
});
/** The request's actor; 401 when the request carries none. */
export const requireContextActor = (): Actor => {
const actor = Context.get('actor');
if (!actor) throw authenticationRequired();
return actor;
};
/** The request's actor, which must act for a user; 401 otherwise. */
export const requireContextUserActor = (): UserActor => {
const actor = Context.get('actor');
if (!actor?.user?.id) throw authenticationRequired();
return actor as UserActor;
};
/**
* The driver-level twin of the HTTP `requireVerified` gate, so `/drivers/call`
* can't bypass it. Inert unless `strict_email_verification_required` is set:
* installs without SMTP must not be bricked.
*/
export const assertActorEmailVerified = (
actor: Actor,
config: { strict_email_verification_required?: unknown },
): void => {
assertVerifiedEmail(
Boolean(config.strict_email_verification_required),
actor.user,
400,
);
};
/**
* Whether `actor` owns a row: its user is the row's owner, and an app actor is
* also the app that created it. Both halves matter, since `app_owner` alone is
* shared by every user of that app.
*/
export const actorOwnsRow = (
actor: Actor,
row: { ownerUserId: unknown; appOwnerId: unknown },
): boolean => {
const app = actor.effectiveApp;
if (app?.id && app.id !== row.appOwnerId) return false;
return actor.user?.id === row.ownerUserId;
};
export const isSystemActor = (actor: Actor | undefined | null): boolean => {
return !!actor?.system || actor?.user?.uuid === SYSTEM_ACTOR_UUID;
};
+16 -44
View File
@@ -17,7 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Context } from '../../core/context.js';
import { actorOwnsRow, requireContextActor } from '../../core/actor.js';
import { HttpError } from '../../core/http/HttpError.js';
import {
DEFAULT_FREE_SUBSCRIPTION,
@@ -50,6 +50,7 @@ import {
WEB_AND_EXTENSION_PROTOCOLS,
} from '../../util/validation.js';
import { PuterDriver } from '../types.js';
import { CRUD_CONCURRENT } from '../util/crudLimits.js';
/**
* Shared by every method that writes an app row. Each one also allocates or
@@ -176,16 +177,7 @@ export class AppDriver extends PuterDriver {
},
};
/** @type {import('../meta.js').DriverConcurrentConfig} */
concurrent = {
default: {
limit: 20,
bySubscription: {
[DEFAULT_FREE_SUBSCRIPTION]: 10,
[DEFAULT_TEMP_SUBSCRIPTION]: 5,
},
},
};
concurrent = CRUD_CONCURRENT;
get appStore() {
return this.stores.app;
@@ -202,7 +194,7 @@ export class AppDriver extends PuterDriver {
legacyCode: 'bad_request',
});
}
const actor = this.#requireActor();
const actor = requireContextActor();
this.#requireUserOrAppActor(actor);
const fields = await this.#validateInput(object, { isCreate: true });
@@ -286,7 +278,7 @@ export class AppDriver extends PuterDriver {
}
async read({ uid, id, params = {}, ...rest } = {}) {
const actor = this.#requireActor();
const actor = requireContextActor();
const app = await this.#resolve({ uid, id });
if (!app)
throw new HttpError(404, 'App not found', {
@@ -331,7 +323,7 @@ export class AppDriver extends PuterDriver {
*/
async select(args = {}) {
const { predicate, params = {} } = args;
const actor = this.#requireActor();
const actor = requireContextActor();
this.#requireUserOrAppActor(actor);
const limit = normalizeLimit(args.limit, { cap: 5000 }) ?? 500;
@@ -411,7 +403,7 @@ export class AppDriver extends PuterDriver {
legacyCode: 'bad_request',
});
}
const actor = this.#requireActor();
const actor = requireContextActor();
this.#requireUserOrAppActor(actor);
const app = await this.#resolve({ uid, id });
@@ -497,7 +489,7 @@ export class AppDriver extends PuterDriver {
}
async delete({ uid, id } = {}) {
const actor = this.#requireActor();
const actor = requireContextActor();
this.#requireUserOrAppActor(actor);
const app = await this.#resolve({ uid, id });
@@ -755,15 +747,6 @@ export class AppDriver extends PuterDriver {
// -- Permission checks --------------------------------------------
#requireActor() {
const actor = Context.get('actor');
if (!actor)
throw new HttpError(401, 'Authentication required', {
legacyCode: 'unauthorized',
});
return actor;
}
#requireUserOrAppActor(actor) {
if (!actor.user)
throw new HttpError(403, 'User actor required', {
@@ -812,26 +795,15 @@ export class AppDriver extends PuterDriver {
}
async #checkWriteAccess(app, actor) {
// App actor matching app_owner
const ownApp = actor.effectiveApp;
let hasAccess = false;
if (!ownApp?.id) {
hasAccess = actor.user?.id === app.owner_user_id;
} else if (ownApp.id === app.app_owner) {
hasAccess = actor.user?.id === app.owner_user_id;
}
// System-wide write
if (!hasAccess) {
hasAccess = await this.permService.check(
actor,
'system:es:write-all-owners',
);
}
if (!hasAccess) {
throw new HttpError(403, 'Access denied', {
legacyCode: 'forbidden',
});
const owns = actorOwnsRow(actor, {
ownerUserId: app.owner_user_id,
appOwnerId: app.app_owner,
});
if (owns) return;
if (await this.permService.check(actor, 'system:es:write-all-owners')) {
return;
}
throw new HttpError(403, 'Access denied', { legacyCode: 'forbidden' });
}
// -- Serialization ------------------------------------------------
@@ -17,8 +17,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { assertResolvedActor } from '../../core/actor.js';
import { Context } from '../../core/context.js';
import {
assertResolvedActor,
requireContextActor,
type UserActor,
} from '../../core/actor.js';
import { HttpError } from '../../core/http/HttpError.js';
import { resolveNotifFetch } from '../../services/events/notifFetch.js';
import {
@@ -38,7 +41,8 @@ import {
} from '../../services/notification/notificationTypes.js';
import { PuterDriver } from '../types.js';
import type { Actor } from '../../core/actor.js';
import type { DriverConcurrentConfig, DriverRateLimitConfig } from '../meta.js';
import type { DriverRateLimitConfig } from '../meta.js';
import { CRUD_CONCURRENT } from '../util/crudLimits.js';
const MAX_SELECT_LIMIT = 200;
@@ -110,15 +114,7 @@ export class NotificationDriver extends PuterDriver {
},
};
readonly concurrent: DriverConcurrentConfig = {
default: {
limit: 20,
bySubscription: {
[DEFAULT_FREE_SUBSCRIPTION]: 10,
[DEFAULT_TEMP_SUBSCRIPTION]: 5,
},
},
};
readonly concurrent = CRUD_CONCURRENT;
// -- Driver methods ----------------------------------------------
@@ -277,14 +273,8 @@ export class NotificationDriver extends PuterDriver {
// -- Permissions -------------------------------------------------
#requireActor(): Actor & {
user: { id: number; uuid: string; username: string };
} {
const actor = Context.get('actor') as Actor | undefined;
if (!actor)
throw new HttpError(401, 'Authentication required', {
legacyCode: 'unauthorized',
});
#requireActor(): UserActor {
const actor = requireContextActor();
if (!actor.user?.id)
throw new HttpError(403, 'User actor required', {
legacyCode: 'forbidden',
@@ -292,15 +282,11 @@ export class NotificationDriver extends PuterDriver {
// Unresolved is not "no app", and reading it that way here is what
// would hand an app the account-wide mailbox.
assertResolvedActor(actor);
return actor as Actor & {
user: { id: number; uuid: string; username: string };
};
return actor as UserActor;
}
/** Writing a notification stays the holder's, per the token table. */
#requireUserActor(): Actor & {
user: { id: number; uuid: string; username: string };
} {
#requireUserActor(): UserActor {
const actor = this.#requireActor();
if (actor.effectiveApp)
throw new HttpError(403, 'App actors cannot create notifications', {
@@ -18,16 +18,20 @@
*/
import { posix as pathPosix } from 'node:path';
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import { assertVerifiedEmail } from '../../core/http/verifiedEmail.js';
import {
DEFAULT_FREE_SUBSCRIPTION,
DEFAULT_TEMP_SUBSCRIPTION,
} from '../../services/metering/consts.js';
import { PuterDriver } from '../types.js';
import type { Actor } from '../../core/actor.js';
import type { DriverConcurrentConfig, DriverRateLimitConfig } from '../meta.js';
import {
actorOwnsRow,
assertActorEmailVerified,
requireContextUserActor,
type Actor,
} from '../../core/actor.js';
import type { DriverRateLimitConfig } from '../meta.js';
import { CRUD_CONCURRENT } from '../util/crudLimits.js';
import type { FSEntry } from '../../stores/fs/FSEntry.js';
import type { UserRow } from '../../stores/user/UserStore.js';
import type { AppView } from '../../util/appView.js';
@@ -124,15 +128,7 @@ export class SubdomainDriver extends PuterDriver {
},
};
readonly concurrent: DriverConcurrentConfig = {
default: {
limit: 20,
bySubscription: {
[DEFAULT_FREE_SUBSCRIPTION]: 10,
[DEFAULT_TEMP_SUBSCRIPTION]: 5,
},
},
};
readonly concurrent = CRUD_CONCURRENT;
// -- Driver methods ----------------------------------------------
@@ -144,9 +140,8 @@ export class SubdomainDriver extends PuterDriver {
legacyCode: 'bad_request',
});
}
const actor = this.#requireActor();
this.#requireUser(actor);
this.#requireVerified(actor);
const actor = requireContextUserActor();
assertActorEmailVerified(actor, this.config);
const subdomain = this.#validateSubdomain(object.subdomain);
@@ -250,7 +245,7 @@ export class SubdomainDriver extends PuterDriver {
}
async read(args: Record<string, unknown>): Promise<unknown> {
const actor = this.#requireActor();
const actor = requireContextUserActor();
const row = await this.#resolve(args);
// Worker deployments live in this table but aren't sites. `select`
// excludes them and the workers driver serves them under its own
@@ -271,8 +266,7 @@ export class SubdomainDriver extends PuterDriver {
}
async select(args: Record<string, unknown>): Promise<unknown> {
const actor = this.#requireActor();
this.#requireUser(actor);
const actor = requireContextUserActor();
const predicate = args.predicate as unknown[] | string | undefined;
const limit = normalizeLimit(args.limit, { cap: 5000 }) ?? 5000;
@@ -359,9 +353,8 @@ export class SubdomainDriver extends PuterDriver {
legacyCode: 'bad_request',
});
}
const actor = this.#requireActor();
this.#requireUser(actor);
this.#requireVerified(actor);
const actor = requireContextUserActor();
assertActorEmailVerified(actor, this.config);
const row = await this.#resolve(args);
if (!row)
@@ -430,9 +423,8 @@ export class SubdomainDriver extends PuterDriver {
}
async delete(args: Record<string, unknown>): Promise<unknown> {
const actor = this.#requireActor();
this.#requireUser(actor);
this.#requireVerified(actor);
const actor = requireContextUserActor();
assertActorEmailVerified(actor, this.config);
const row = await this.#resolve(args);
if (!row)
@@ -519,40 +511,6 @@ export class SubdomainDriver extends PuterDriver {
// -- Permissions -------------------------------------------------
#requireActor(): Actor & {
user: { id: number; uuid: string; username: string };
} {
const actor = Context.get('actor') as Actor | undefined;
if (!actor?.user?.id)
throw new HttpError(401, 'Authentication required', {
legacyCode: 'unauthorized',
});
return actor as Actor & {
user: { id: number; uuid: string; username: string };
};
}
#requireUser(actor: Actor): void {
if (!actor.user?.id)
throw new HttpError(403, 'User actor required', {
legacyCode: 'forbidden',
});
}
/**
* Mirror of the HTTP-layer `requireVerifiedGate` on /delete-site — only
* active when `strict_email_verification_required` is truthy, so self-
* hosted installs without SMTP aren't bricked. Applied at the driver level
* so /drivers/call can't bypass the gate the HTTP route enforces.
*/
#requireVerified(actor: Actor): void {
assertVerifiedEmail(
Boolean(this.config.strict_email_verification_required),
actor.user,
400,
);
}
async #hasPermission(actor: Actor, permission: string): Promise<boolean> {
try {
return await this.services.permission.check(actor, permission);
@@ -626,26 +584,15 @@ export class SubdomainDriver extends PuterDriver {
row: Record<string, unknown>,
actor: Actor,
): Promise<void> {
// App actor matching app_owner
const app = actor.effectiveApp;
let hasAccess = false;
if (!app?.id) {
hasAccess = actor.user?.id === row.user_id;
} else if (app.id === row.app_owner) {
hasAccess = actor.user?.id === row.user_id;
}
// System-wide write
if (!hasAccess) {
hasAccess = await this.#hasPermission(
actor,
'system:es:write-all-owners',
);
}
if (!hasAccess) {
throw new HttpError(403, 'Access denied', {
legacyCode: 'forbidden',
});
const owns = actorOwnsRow(actor, {
ownerUserId: row.user_id,
appOwnerId: row.app_owner,
});
if (owns) return;
if (await this.#hasPermission(actor, 'system:es:write-all-owners')) {
return;
}
throw new HttpError(403, 'Access denied', { legacyCode: 'forbidden' });
}
// -- Config ------------------------------------------------------
+38
View File
@@ -0,0 +1,38 @@
/*
* 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/>.
*/
import {
DEFAULT_FREE_SUBSCRIPTION,
DEFAULT_TEMP_SUBSCRIPTION,
} from '../../services/metering/consts.js';
import type { DriverConcurrentConfig } from '../meta.js';
/**
* In-flight cap shared by the crud-q drivers (apps, subdomains, notifications),
* which inherited one pre-v2 `temp.es` / `user.es` policy.
*/
export const CRUD_CONCURRENT: DriverConcurrentConfig = {
default: {
limit: 20,
bySubscription: {
[DEFAULT_FREE_SUBSCRIPTION]: 10,
[DEFAULT_TEMP_SUBSCRIPTION]: 5,
},
},
};
+11 -35
View File
@@ -21,10 +21,13 @@
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import type { EventMetadata } from '../../clients/event/types.js';
import { makeActor, type Actor } from '../../core/actor.js';
import { Context } from '../../core/context.js';
import {
assertActorEmailVerified,
makeActor,
requireContextUserActor,
type Actor,
} from '../../core/actor.js';
import { HttpError, type LegacyErrorCodes } from '../../core/http/HttpError.js';
import { assertVerifiedEmail } from '../../core/http/verifiedEmail.js';
import type { FSEntry } from '../../stores/fs/FSEntry.js';
import {
WORKER_SUBDOMAIN_PREFIX,
@@ -327,12 +330,12 @@ export class WorkerDriver extends PuterDriver {
[INTERNAL_ADMISSION_BYPASS]?: boolean;
[INTERNAL_DEPLOY_TARGET]?: InternalDeployTarget;
}): Promise<unknown> {
const actor = this.#requireActor();
const actor = requireContextUserActor();
const skipAdmission = args[INTERNAL_ADMISSION_BYPASS] === true;
const deployTarget = this.#validateDeployTarget(
args[INTERNAL_DEPLOY_TARGET],
);
if (!skipAdmission) this.#requireVerified(actor);
if (!skipAdmission) assertActorEmailVerified(actor, this.config);
const workerName = String(args.workerName ?? '').toLowerCase();
const filePath = String(args.filePath ?? '');
// `effectiveApp`, not `app`: a token an app issued carries no app of
@@ -539,8 +542,8 @@ export class WorkerDriver extends PuterDriver {
}
async destroy(args: Record<string, unknown>): Promise<unknown> {
const actor = this.#requireActor();
this.#requireVerified(actor);
const actor = requireContextUserActor();
assertActorEmailVerified(actor, this.config);
const workerName = String(args.workerName ?? '').toLowerCase();
if (!workerName)
throw new HttpError(400, 'Missing `workerName`', {
@@ -573,7 +576,7 @@ export class WorkerDriver extends PuterDriver {
}
async getFilePaths(args: Record<string, unknown>): Promise<unknown> {
const actor = this.#requireActor();
const actor = requireContextUserActor();
const workerName = args.workerName as string | undefined;
const limit = normalizeLimit(args.limit, { cap: 5000 });
@@ -840,19 +843,6 @@ export class WorkerDriver extends PuterDriver {
// -- Helpers ------------------------------------------------------
#requireActor(): Actor & {
user: { id: number; uuid: string; username: string };
} {
const actor = Context.get('actor') as Actor | undefined;
if (!actor?.user?.id)
throw new HttpError(401, 'Authentication required', {
legacyCode: 'unauthorized',
});
return actor as Actor & {
user: { id: number; uuid: string; username: string };
};
}
/**
* Only in-process code can supply a deploy target, so this is not a trust
* boundary — it is a guard against a caller putting a path separator or a
@@ -1028,20 +1018,6 @@ export class WorkerDriver extends PuterDriver {
return this.config.workers ?? {};
}
/**
* Mirror of the HTTP-layer `requireVerifiedGate` on /delete-site — only
* active when `strict_email_verification_required` is truthy, so self-
* hosted installs without SMTP aren't bricked. Applied at the driver level
* so /drivers/call can't bypass the gate the HTTP route enforces.
*/
#requireVerified(actor: Actor): void {
assertVerifiedEmail(
Boolean(this.config.strict_email_verification_required),
actor.user,
400,
);
}
// -- Hot-reload: auto-redeploy on source file write --------------
//
// When a user saves a JS file that's tied to a worker subdomain,