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.
This commit is contained in:
Daniel Salazar committed 2026-10-10 19:45:27 -04:00
1 parent 12bedbe4bf
commit 1d099746d4
8 files changed
+220 -320

No files matched your search

@@ -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 <https://www.gnu.org/licenses/>.
*/
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),
);
};
};
@@ -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
@@ -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
@@ -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', () => {
+161 -159
View File
@@ -17,7 +17,16 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<void>,
): RequestHandler => {
return (req, _res, next) => {
let pending: void | Promise<void>;
try {
pending = check(req);
} catch (err) {
next(err);
return;
}
if (!pending) {
next();
return;
}
return settleGate(pending, next);
};
};
const settleGate = async (
pending: Promise<void>,
next: NextFunction,
): Promise<void> => {
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<AnyVerifiedDeps, 'hasPaidPlan'>,
): 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<SubscriptionGateHook, 'req'>,
): 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));
@@ -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 <https://www.gnu.org/licenses/>.
*/
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),
);
};
};
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<SubscriptionGateHook, 'req'>,
): RequestHandler => {
return (req, _res, next) => {
assertActorHasSubscription(
metering,
req.actor,
requirement,
config,
hook && { ...hook, req },
).then(
() => next(),
(err) => next(err),
);
};
};
+3 -3
View File
@@ -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,