diff --git a/extensions/appTelemetry.ts b/extensions/appTelemetry.ts index 43f62050e..a1ff3eae8 100644 --- a/extensions/appTelemetry.ts +++ b/extensions/appTelemetry.ts @@ -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; diff --git a/extensions/installedApps.test.ts b/extensions/installedApps.test.ts index f42c59523..019a168a8 100644 --- a/extensions/installedApps.test.ts +++ b/extensions/installedApps.test.ts @@ -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(); diff --git a/extensions/installedApps.ts b/extensions/installedApps.ts index 7c460eb42..1c5446e01 100644 --- a/extensions/installedApps.ts +++ b/extensions/installedApps.ts @@ -28,8 +28,8 @@ export const handleInstalledApps = async ( req: Request, res: Response, ): Promise => { - 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)) { diff --git a/extensions/metering.test.ts b/extensions/metering.test.ts index 2586c96a6..29fa2ce62 100644 --- a/extensions/metering.test.ts +++ b/extensions/metering.test.ts @@ -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(); diff --git a/extensions/metering.ts b/extensions/metering.ts index cbc7248ff..ae190a2cd 100644 --- a/extensions/metering.ts +++ b/extensions/metering.ts @@ -95,8 +95,8 @@ export const handleMeteringUsage = async ( _req: Request, res: Response, ): Promise => { - 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 => { - 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'); diff --git a/src/backend/core/actor.guards.test.ts b/src/backend/core/actor.guards.test.ts new file mode 100644 index 000000000..4e15676d7 --- /dev/null +++ b/src/backend/core/actor.guards.test.ts @@ -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 . + */ + +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, + ); + }); +}); diff --git a/src/backend/core/actor.ts b/src/backend/core/actor.ts index 433d9f461..b90ee851e 100644 --- a/src/backend/core/actor.ts +++ b/src/backend/core/actor.ts @@ -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; }; diff --git a/src/backend/drivers/apps/AppDriver.js b/src/backend/drivers/apps/AppDriver.js index 75a8dba4a..255d421c8 100644 --- a/src/backend/drivers/apps/AppDriver.js +++ b/src/backend/drivers/apps/AppDriver.js @@ -17,7 +17,7 @@ * along with this program. If not, see . */ -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 ------------------------------------------------ diff --git a/src/backend/drivers/notification/NotificationDriver.ts b/src/backend/drivers/notification/NotificationDriver.ts index aa57155d3..b49986909 100644 --- a/src/backend/drivers/notification/NotificationDriver.ts +++ b/src/backend/drivers/notification/NotificationDriver.ts @@ -17,8 +17,11 @@ * along with this program. If not, see . */ -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', { diff --git a/src/backend/drivers/subdomain/SubdomainDriver.ts b/src/backend/drivers/subdomain/SubdomainDriver.ts index 5fbd7ced5..6510f7f45 100644 --- a/src/backend/drivers/subdomain/SubdomainDriver.ts +++ b/src/backend/drivers/subdomain/SubdomainDriver.ts @@ -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): Promise { - 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): Promise { - 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): Promise { - 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 { try { return await this.services.permission.check(actor, permission); @@ -626,26 +584,15 @@ export class SubdomainDriver extends PuterDriver { row: Record, actor: Actor, ): Promise { - // 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 ------------------------------------------------------ diff --git a/src/backend/drivers/util/crudLimits.ts b/src/backend/drivers/util/crudLimits.ts new file mode 100644 index 000000000..8c96b09e5 --- /dev/null +++ b/src/backend/drivers/util/crudLimits.ts @@ -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 . + */ + +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, + }, + }, +}; diff --git a/src/backend/drivers/workers/WorkerDriver.ts b/src/backend/drivers/workers/WorkerDriver.ts index ed44e9630..b4846e7c1 100644 --- a/src/backend/drivers/workers/WorkerDriver.ts +++ b/src/backend/drivers/workers/WorkerDriver.ts @@ -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 { - 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): Promise { - 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): Promise { - 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,