From 1d099746d4d7d710b03023760002ee3f80b3ff27 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Sat, 10 Oct 2026 19:01:05 -0400 Subject: [PATCH] refactor(http): build route gates from one gate() helper Each gate repeated a try/catch -> next(err) wrapper or a .then(next, next) chain around an assertion. gate(check) does that once: a throw or rejection goes to next(err), and a synchronous check still calls next synchronously. The credits, subscription and reputation gate files held nothing but such a wrapper; their factories move into gates.ts and the files go. --- src/backend/core/http/middleware/credits.ts | 50 --- ...ation.test.ts => gates.reputation.test.ts} | 2 +- ...ion.test.ts => gates.subscription.test.ts} | 2 +- .../core/http/middleware/gates.test.ts | 54 +++ src/backend/core/http/middleware/gates.ts | 320 +++++++++--------- .../core/http/middleware/reputation.ts | 48 --- .../core/http/middleware/subscription.ts | 58 ---- src/backend/server.ts | 6 +- 8 files changed, 220 insertions(+), 320 deletions(-) delete mode 100644 src/backend/core/http/middleware/credits.ts rename src/backend/core/http/middleware/{reputation.test.ts => gates.reputation.test.ts} (97%) rename src/backend/core/http/middleware/{subscription.test.ts => gates.subscription.test.ts} (98%) delete mode 100644 src/backend/core/http/middleware/reputation.ts delete mode 100644 src/backend/core/http/middleware/subscription.ts diff --git a/src/backend/core/http/middleware/credits.ts b/src/backend/core/http/middleware/credits.ts deleted file mode 100644 index cfc54ff10..000000000 --- a/src/backend/core/http/middleware/credits.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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 type { RequestHandler } from 'express'; -import type { IConfig } from '../../../types'; -import { - assertActorHasCredits, - type CreditMetering, -} from '../../../services/metering/enforcement.js'; -import '../expressAugmentation'; - -/** - * Reject an authenticated caller with nothing left of their budget, for routes - * that opt in with `requireCredits`. - * - * Anonymous callers pass: the signed-URL routes authorize on the URL rather - * than a session, and there is no account to charge or turn away. So do worker - * sessions, unless configured otherwise — see `creditEnforcementExempt`. - * - * The answer comes from a short-lived per-actor cache in the metering service, - * so this normally costs nothing beyond a map lookup. That is what makes it - * affordable on routes that are called hundreds of times a minute. - */ -export const requireCreditsGate = ( - metering: CreditMetering | undefined, - config: IConfig, -): RequestHandler => { - return (req, _res, next) => { - assertActorHasCredits(metering, req.actor, config).then( - () => next(), - (err) => next(err), - ); - }; -}; diff --git a/src/backend/core/http/middleware/reputation.test.ts b/src/backend/core/http/middleware/gates.reputation.test.ts similarity index 97% rename from src/backend/core/http/middleware/reputation.test.ts rename to src/backend/core/http/middleware/gates.reputation.test.ts index d297a13cb..f422c0434 100644 --- a/src/backend/core/http/middleware/reputation.test.ts +++ b/src/backend/core/http/middleware/gates.reputation.test.ts @@ -21,7 +21,7 @@ import type { Request, Response } from 'express'; import { describe, expect, it } from 'vitest'; import type { IConfig } from '../../../types'; import { isHttpError } from '../HttpError'; -import { requireReputationGate } from './reputation'; +import { requireReputationGate } from './gates'; // The decision itself is covered in `core/reputation.test.ts`; what matters // here is that the gate resolves it and hands the outcome to `next()` rather diff --git a/src/backend/core/http/middleware/subscription.test.ts b/src/backend/core/http/middleware/gates.subscription.test.ts similarity index 98% rename from src/backend/core/http/middleware/subscription.test.ts rename to src/backend/core/http/middleware/gates.subscription.test.ts index 829b76573..e041b2408 100644 --- a/src/backend/core/http/middleware/subscription.test.ts +++ b/src/backend/core/http/middleware/gates.subscription.test.ts @@ -22,7 +22,7 @@ import { describe, expect, it, vi } from 'vitest'; import { EventClient } from '../../../clients/event/EventClient'; import type { IConfig } from '../../../types'; import { isHttpError } from '../HttpError'; -import { requireSubscriptionGate } from './subscription'; +import { requireSubscriptionGate } from './gates'; // The decision itself is covered in `services/metering/enforcement.test.ts`; // what matters here is that the gate resolves it and hands the outcome to diff --git a/src/backend/core/http/middleware/gates.test.ts b/src/backend/core/http/middleware/gates.test.ts index 804c86d8f..ec339c8b4 100644 --- a/src/backend/core/http/middleware/gates.test.ts +++ b/src/backend/core/http/middleware/gates.test.ts @@ -28,6 +28,7 @@ import { adminOnlyGate, allowedAppIdsGate, assertNotUserSession, + gate, noUserSessionGate, requireAnyVerifiedGate, type AnyVerifiedDeps, @@ -117,6 +118,59 @@ const expectHttpError = (got: NextArg, status: number, legacyCode?: string) => { if (legacyCode) expect(err.legacyCode).toBe(legacyCode); }; +// ── gate ──────────────────────────────────────────────────────────── + +describe('gate', () => { + const denied = () => + new HttpError(403, 'nope', { legacyCode: 'forbidden' }); + + it('calls next() synchronously when a sync check passes', () => { + expect(runGate(gate(() => {}), {})).toBeUndefined(); + }); + + it('hands a sync throw to next(err) synchronously', () => { + const err = denied(); + expect( + runGate( + gate(() => { + throw err; + }), + {}, + ), + ).toBe(err); + }); + + it('calls next() once an async check resolves', async () => { + const got = await runGateAsync( + gate(async () => {}), + {}, + ); + expect(got).toBeUndefined(); + }); + + it('hands an async rejection to next(err)', async () => { + const err = denied(); + const got = await runGateAsync( + gate(async () => { + throw err; + }), + {}, + ); + expect(got).toBe(err); + }); + + it('passes the request to the check', () => { + let seen: unknown; + runGate( + gate((req) => { + seen = req.headers; + }), + { headers: { host: 'x' } }, + ); + expect(seen).toEqual({ host: 'x' }); + }); +}); + // ── subdomainGate ─────────────────────────────────────────────────── describe('subdomainGate', () => { diff --git a/src/backend/core/http/middleware/gates.ts b/src/backend/core/http/middleware/gates.ts index 9b2e33eb3..aae513c23 100644 --- a/src/backend/core/http/middleware/gates.ts +++ b/src/backend/core/http/middleware/gates.ts @@ -17,7 +17,16 @@ * along with this program. If not, see . */ -import type { Request, RequestHandler } from 'express'; +import type { NextFunction, Request, RequestHandler } from 'express'; +import { + assertActorHasCredits, + assertActorHasSubscription, + type CreditMetering, + type SubscriptionGateHook, + type SubscriptionMetering, + type SubscriptionRequirement, +} from '../../../services/metering/enforcement.js'; +import type { IConfig } from '../../../types'; import { isCardVerificationEnabled, type CardFallbackDeps, @@ -29,6 +38,10 @@ import { isPlainUserActor, type Actor, } from '../../actor'; +import { + assertActorMeetsReputation, + type ReputationRequirement, +} from '../../reputation.js'; import { HttpError } from '../HttpError'; import type { AccountGateUser, VerificationFactor } from '../types'; import { assertVerifiedEmail } from '../verifiedEmail'; @@ -62,6 +75,43 @@ const rejectAuth = (req: Request): HttpError => { }); }; +/** + * Route middleware from an assertion: a throw or a rejection goes to + * `next(err)`, anything else to `next()`. A synchronous check calls `next` + * synchronously. + */ +export const gate = ( + check: (req: Request) => void | Promise, +): RequestHandler => { + return (req, _res, next) => { + let pending: void | Promise; + try { + pending = check(req); + } catch (err) { + next(err); + return; + } + if (!pending) { + next(); + return; + } + return settleGate(pending, next); + }; +}; + +const settleGate = async ( + pending: Promise, + next: NextFunction, +): Promise => { + try { + await pending; + } catch (err) { + next(err); + return; + } + next(); +}; + /** * Skip this route entirely (via `next('route')`) when the request's leftmost * subdomain doesn't match. This _isn't_ a rejection — it lets a different route @@ -82,31 +132,18 @@ export const subdomainGate = (allowed: string | string[]): RequestHandler => { }; /** 401 for anonymous requests, 403 for suspended accounts. */ -export const requireAuthGate = (): RequestHandler => { - return (req, _res, next) => { +export const requireAuthGate = (): RequestHandler => + gate((req) => { if (req.appBlocked) { - next( - new HttpError( - 403, - 'This app is not allowed to access Puter resources', - { legacyCode: 'app_blocked' }, - ), + throw new HttpError( + 403, + 'This app is not allowed to access Puter resources', + { legacyCode: 'app_blocked' }, ); - return; } - if (!req.actor) { - next(rejectAuth(req)); - return; - } - try { - assertNotSuspended(req.actor.user); - } catch (err) { - next(err); - return; - } - next(); - }; -}; + if (!req.actor) throw rejectAuth(req); + assertNotSuspended(req.actor.user); + }); /** * Reject app-under-user and access-token actors with 403. `allowFullAccess` @@ -116,14 +153,11 @@ export const requireAuthGate = (): RequestHandler => { */ export const requireUserActorGate = ( opts: { allowFullAccess?: boolean } = {}, -): RequestHandler => { - return (req, _res, next) => { +): RequestHandler => + gate((req) => { const actor = req.actor; // requireAuth runs first; this gate just narrows the actor type. - if (!actor) { - next(rejectAuth(req)); - return; - } + if (!actor) throw rejectAuth(req); const appBlocked = isAppActor(actor); // `isAccountContext`, not the `fullAccess` flag: an app anywhere in the // chain, or an actor that skipped `makeActor`, answers no. @@ -131,18 +165,13 @@ export const requireUserActorGate = ( !!actor.accessToken && !(opts.allowFullAccess && isAccountContext(actor)); if (appBlocked || tokenBlocked) { - next( - new HttpError( - 403, - 'This endpoint is only available to user sessions', - { legacyCode: 'forbidden' }, - ), + throw new HttpError( + 403, + 'This endpoint is only available to user sessions', + { legacyCode: 'forbidden' }, ); - return; } - next(); - }; -}; + }); /** * Reject bare account-session actors (no app, no access token) so a session @@ -168,45 +197,26 @@ export const assertNotUserSession = ( }; /** Route-option form of {@link assertNotUserSession} (`noUserSession: true`). */ -export const noUserSessionGate = (): RequestHandler => { - return (req, _res, next) => { - const actor = req.actor; - if (!actor) { - next(rejectAuth(req)); - return; - } - try { - assertNotUserSession(actor); - } catch (err) { - next(err); - return; - } - next(); - }; -}; +export const noUserSessionGate = (): RequestHandler => + gate((req) => { + if (!req.actor) throw rejectAuth(req); + assertNotUserSession(req.actor); + }); -export const requireNonAccessTokenGate = (): RequestHandler => { - return (req, _res, next) => { +export const requireNonAccessTokenGate = (): RequestHandler => + gate((req) => { const actor = req.actor; - if (!actor) { - next(rejectAuth(req)); - return; - } + if (!actor) throw rejectAuth(req); // Full-access tokens pass; `requireUserActorGate` still keeps them // off account management. if (actor.accessToken && !actor.accessToken.fullAccess) { - next( - new HttpError( - 403, - 'Access tokens are not allowed to access this resource', - { legacyCode: 'forbidden' }, - ), + throw new HttpError( + 403, + 'Access tokens are not allowed to access this resource', + { legacyCode: 'forbidden' }, ); - return; } - next(); - }; -}; + }); /** Built-in admin usernames that always pass `adminOnly`. */ export const DEFAULT_ADMIN_USERNAMES = ['admin', 'system'] as const; @@ -228,27 +238,19 @@ export const adminOnlyGate = ( ); // Admin follows the name, so a name that grants it must never be claimable. reserveUsernames(allowList); - return (req, _res, next) => { + return gate((req) => { const username = req.actor?.user.username; - if (!username || !allowList.has(username.toLowerCase())) { - next( - new HttpError(403, 'Only admins may request this resource', { - legacyCode: 'forbidden', - }), - ); - return; - } const chainApp = req.actor?.effectiveApp ?? null; - if (chainApp && !(opts.appGated && isAppActor(req.actor))) { - next( - new HttpError(403, 'Only admins may request this resource', { - legacyCode: 'forbidden', - }), - ); - return; + if ( + !username || + !allowList.has(username.toLowerCase()) || + (chainApp && !(opts.appGated && isAppActor(req.actor))) + ) { + throw new HttpError(403, 'Only admins may request this resource', { + legacyCode: 'forbidden', + }); } - next(); - }; + }); }; /** @@ -256,17 +258,8 @@ export const adminOnlyGate = ( * `strict_email_verification_required` is set, so deployments without email * delivery are not locked out. */ -export const requireVerifiedGate = (strictFlag: boolean): RequestHandler => { - return (req, _res, next) => { - try { - assertVerifiedEmail(strictFlag, req.actor?.user); - } catch (err) { - next(err); - return; - } - next(); - }; -}; +export const requireVerifiedGate = (strictFlag: boolean): RequestHandler => + gate((req) => assertVerifiedEmail(strictFlag, req.actor?.user)); /** Just the seat read the 2FA gate needs, so gates stay store-agnostic. */ export interface Team2faLookup { @@ -298,28 +291,15 @@ export const assertTeam2fa = async ( /** {@link assertTeam2fa} as route middleware. */ export const requireTeam2fa = ( teams: Team2faLookup | undefined, -): RequestHandler => { - return (req, _res, next) => { - assertTeam2fa(req.actor?.user, teams).then(() => next(), next); - }; -}; +): RequestHandler => gate((req) => assertTeam2fa(req.actor?.user, teams)); /** * Reject accounts still pending a signup-time verification (email, phone, card, * password change). Default-on for authenticated routes; the flows that clear * these flags opt out with `allowUnconfirmed: true`. */ -export const requireVerifiedAccount = (): RequestHandler => { - return (req, _res, next) => { - try { - assertVerifiedAccount(req.actor?.user); - } catch (err) { - next(err); - return; - } - next(); - }; -}; +export const requireVerifiedAccount = (): RequestHandler => + gate((req) => assertVerifiedAccount(req.actor?.user)); /** * The check behind {@link requireVerifiedAccount}, for auth paths that build @@ -389,17 +369,8 @@ export const assertPhoneVerified = ( }; /** Route-option form of {@link assertPhoneVerified} (`requirePhoneVerified`). */ -export const requirePhoneVerifiedGate = (): RequestHandler => { - return (req, _res, next) => { - try { - assertPhoneVerified(req.actor?.user); - } catch (err) { - next(err); - return; - } - next(); - }; -}; +export const requirePhoneVerifiedGate = (): RequestHandler => + gate((req) => assertPhoneVerified(req.actor?.user)); export interface AnyVerifiedDeps extends CardFallbackDeps { /** A paid plan implies a card on file, so it counts as card-verified. */ @@ -419,21 +390,13 @@ const hasCardEvidence = async ( */ export const requireCardVerifiedGate = ( deps: Pick, -): RequestHandler => { - return (req, _res, next) => { - hasCardEvidence(req.actor, deps).then((verified) => { - if (verified) { - next(); - return; - } - next( - new HttpError(403, CARD_REQUIRED_MESSAGE, { - legacyCode: 'card_verification_required', - }), - ); - }, next); - }; -}; +): RequestHandler => + gate(async (req) => { + if (await hasCardEvidence(req.actor, deps)) return; + throw new HttpError(403, CARD_REQUIRED_MESSAGE, { + legacyCode: 'card_verification_required', + }); + }); const isFactorVerified = ( factor: VerificationFactor, @@ -492,14 +455,7 @@ export const assertAnyVerified = async ( export const requireAnyVerifiedGate = ( factors: readonly VerificationFactor[], deps: AnyVerifiedDeps, -): RequestHandler => { - return (req, _res, next) => { - assertAnyVerified(req.actor, factors, deps).then( - () => next(), - (err) => next(err), - ); - }; -}; +): RequestHandler => gate((req) => assertAnyVerified(req.actor, factors, deps)); export const assertNotSuspended = (user: AccountGateUser | undefined): void => { if (user?.suspended) { @@ -519,16 +475,62 @@ export const allowedAppIdsGate = ( allowedAppUids: readonly string[], ): RequestHandler => { const allowList = new Set(allowedAppUids); - return (req, _res, next) => { + return gate((req) => { const appUid = req.actor?.effectiveApp?.uid; if (appUid && !allowList.has(appUid)) { - next( - new HttpError(403, 'This app may not request this resource', { - legacyCode: 'forbidden', - }), - ); - return; + throw new HttpError(403, 'This app may not request this resource', { + legacyCode: 'forbidden', + }); } - next(); - }; + }); }; + +// -- Metering and reputation gates ----------------------------------- +// +// The decisions live in their own modules because the driver dispatch path +// calls them directly: `/drivers/call` is one shared route, so a per-driver +// requirement can't ride on the route chain. + +/** + * Reject a caller whose account isn't trusted enough for this surface + * (`requireReputation`). Inert unless the running config gives the named tier a + * minimum score. + */ +export const requireReputationGate = ( + config: IConfig, + requirement: ReputationRequirement, +): RequestHandler => + gate((req) => assertActorMeetsReputation(req.actor, requirement, config)); + +/** + * Reject a caller whose plan doesn't include this surface + * (`requireSubscription`): `true` accepts any non-free plan, an array of policy + * ids only those. + */ +export const requireSubscriptionGate = ( + metering: SubscriptionMetering | undefined, + config: IConfig, + requirement: SubscriptionRequirement, + hook?: Omit, +): RequestHandler => + gate((req) => + assertActorHasSubscription( + metering, + req.actor, + requirement, + config, + hook && { ...hook, req }, + ), + ); + +/** + * Reject an authenticated caller with nothing left of their budget + * (`requireCredits`). Anonymous callers pass: signed-URL routes authorize on + * the URL, with no account to charge. Answered from the metering service's + * per-actor cache, so it normally costs a map lookup. + */ +export const requireCreditsGate = ( + metering: CreditMetering | undefined, + config: IConfig, +): RequestHandler => + gate((req) => assertActorHasCredits(metering, req.actor, config)); diff --git a/src/backend/core/http/middleware/reputation.ts b/src/backend/core/http/middleware/reputation.ts deleted file mode 100644 index 68c92e54d..000000000 --- a/src/backend/core/http/middleware/reputation.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * 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 type { RequestHandler } from 'express'; -import { - assertActorMeetsReputation, - type ReputationRequirement, -} from '../../reputation.js'; -import type { IConfig } from '../../../types'; -import '../expressAugmentation'; - -/** - * Reject a caller whose account isn't trusted enough for this surface, for - * routes that opt in with `requireReputation`. Off unless a route asks for it, - * and off again unless the running config gives the named tier a minimum - * score. - * - * The decision is `assertActorMeetsReputation`, which the driver dispatch path - * calls directly — `/drivers/call` is one shared route, so a per-driver - * requirement can't ride on the route chain. - */ -export const requireReputationGate = ( - config: IConfig, - requirement: ReputationRequirement, -): RequestHandler => { - return (req, _res, next) => { - assertActorMeetsReputation(req.actor, requirement, config).then( - () => next(), - (err) => next(err), - ); - }; -}; diff --git a/src/backend/core/http/middleware/subscription.ts b/src/backend/core/http/middleware/subscription.ts deleted file mode 100644 index df426b70f..000000000 --- a/src/backend/core/http/middleware/subscription.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * 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 type { RequestHandler } from 'express'; -import { - assertActorHasSubscription, - type SubscriptionGateHook, - type SubscriptionMetering, - type SubscriptionRequirement, -} from '../../../services/metering/enforcement.js'; -import type { IConfig } from '../../../types'; -import '../expressAugmentation'; - -/** - * Reject a caller whose plan doesn't include this surface, for routes that opt - * in with `requireSubscription`. Off unless a route asks for it — no surface is - * subscriber-only by default. - * - * `true` accepts any non-free plan; an array of policy ids accepts only those. - * Either way the decision is `assertActorHasSubscription`, which the driver - * dispatch path calls directly — `/drivers/call` is one shared route, so a - * per-driver requirement can't ride on the route chain. - */ -export const requireSubscriptionGate = ( - metering: SubscriptionMetering | undefined, - config: IConfig, - requirement: SubscriptionRequirement, - hook?: Omit, -): RequestHandler => { - return (req, _res, next) => { - assertActorHasSubscription( - metering, - req.actor, - requirement, - config, - hook && { ...hook, req }, - ).then( - () => next(), - (err) => next(err), - ); - }; -}; diff --git a/src/backend/server.ts b/src/backend/server.ts index 92ce5b247..3d5116545 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -44,7 +44,10 @@ import { requireAnyVerifiedGate, requireAuthGate, requireCardVerifiedGate, + requireCreditsGate, requirePhoneVerifiedGate, + requireReputationGate, + requireSubscriptionGate, requireTeam2fa, requireVerifiedAccount, requireNonAccessTokenGate, @@ -53,9 +56,6 @@ import { subdomainGate, } from './core/http/middleware/gates'; import { guiOriginGate } from './core/http/middleware/originGate'; -import { requireCreditsGate } from './core/http/middleware/credits'; -import { requireReputationGate } from './core/http/middleware/reputation'; -import { requireSubscriptionGate } from './core/http/middleware/subscription'; import { validateReputationRequirement } from './core/reputation'; import { actorOnPaidPlan,