/*
* 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 .
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import compression from 'compression';
import cookieParser from 'cookie-parser';
import express from 'express';
import type { Application, RequestHandler } from 'express';
import helmet from 'helmet';
import uaParser from 'ua-parser-js';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { parseArgs } from 'node:util';
import http from 'node:http';
import { puterClients } from './clients';
import { puterControllers } from './controllers';
import { createAuthProbe } from './core/http/middleware/authProbe';
import { createRequestContextMiddleware } from './core/http/middleware/requestContext';
import { createFingerprintMiddleware } from './core/http/middleware/fingerprint';
import { createErrorHandler } from './core/http/middleware/errorHandler';
import { isHttpError } from './core/http/HttpError';
import {
adminOnlyGate,
allowedAppIdsGate,
noUserSessionGate,
requireAuthGate,
requireCardVerifiedGate,
requirePhoneVerifiedGate,
requireVerifiedAccount,
requireNonAccessTokenGate,
requireUserActorGate,
requireVerifiedGate,
subdomainGate,
} from './core/http/middleware/gates';
import { guiOriginGate } from './core/http/middleware/originGate';
import { requireCreditsGate } from './core/http/middleware/credits';
import { requireSubscriptionGate } from './core/http/middleware/subscription';
import { validateSubscriptionRequirement } from './services/metering/enforcement';
import { createStepUpGate } from './core/http/middleware/stepUpSession';
import { createNotFoundHandler } from './core/http/middleware/notFoundHandler';
import { installProcessGuards } from './util/processGuards';
import {
requireAntiCsrf,
setAntiCsrfRedis,
} from './core/http/middleware/antiCsrf';
import { captchaGate, setCaptchaRedis } from './core/http/middleware/captcha';
import {
concurrencyGate,
configureRateLimit,
rateLimitGate,
} from './core/http/middleware/rateLimit';
import {
createWwwRedirect,
createUserSubdomainRedirect,
createNativeAppStatic,
} from './core/http/middleware/hostRedirects';
import { createEgressMeteringMiddleware } from './core/http/middleware/egressMetering';
import { createLocalWorkerProxyMiddleware } from './core/http/middleware/localWorkerProxy';
import { createPuterSiteMiddleware } from './core/http/middleware/puterSite';
import { PuterRouter } from './core/http/PuterRouter';
import { createRouteLifecycleMiddleware } from './core/http/routeLifecycle';
import { PREFIX_METADATA_KEY, type RouteDescriptor } from './core/http/types';
import type { AuthService } from './services/auth/AuthService';
import { puterDrivers } from './drivers';
import {
clientsContainers,
configContainer,
controllersContainers,
driversContainers,
servicesContainers,
storesContainers,
} from './exports';
import { extensionStore } from './extensions';
import { puterServices } from './services';
import { puterStores } from './stores';
import type {
IConfig,
LayerInstances,
PagerSeverity,
WithControllerRegistration,
WithLifecycle,
} from './types';
export class PuterServer {
clients!: LayerInstances;
stores!: LayerInstances;
services!: LayerInstances;
controllers!: LayerInstances;
drivers!: LayerInstances;
#config: IConfig;
#app!: ReturnType;
#server: ReturnType['listen']> | null = null;
#removeProcessGuards: (() => void) | null = null;
#ready: Promise;
constructor(
config: IConfig,
clients: typeof puterClients = puterClients,
stores: typeof puterStores = puterStores,
services: typeof puterServices = puterServices,
controllers: typeof puterControllers = puterControllers,
drivers: typeof puterDrivers = puterDrivers,
) {
this.#config = config;
// Expose config to the extension API (extension.config)
Object.assign(configContainer, config);
this.#ready = this.#setupServer(
clients,
stores,
services,
controllers,
drivers,
);
}
async #setupServer(
clients: typeof puterClients,
stores: typeof puterStores,
services: typeof puterServices,
controllers: typeof puterControllers,
drivers: typeof puterDrivers,
) {
// Load prod extensions from configured directories (dynamic)
const extensionDirs = this.#config.extensions;
await this.#importExtensions(extensionDirs);
this.clients = {} as typeof this.clients;
for (const [clientName, ClientClass] of Object.entries(clients)) {
// @ts-expect-error as any casting to avoid overly complex or circular types
this.clients[clientName] =
typeof ClientClass === 'object'
? ClientClass
: (new (ClientClass as any)(this.#config) as any);
// @ts-expect-error implicit any casting to avoid overly complex or circular types
clientsContainers[clientName] = this.clients[clientName];
}
for (const [clientName, ClientClass] of Object.entries(
extensionStore.clients,
)) {
// @ts-expect-error as any casting to avoid overly complex or circular types
this.clients[clientName] =
typeof ClientClass === 'object'
? ClientClass
: (new (ClientClass as any)(this.#config) as any);
// @ts-expect-error implicit any casting to avoid overly complex or circular types
clientsContainers[clientName] = this.clients[clientName];
}
this.stores = {} as typeof this.stores;
for (const [storeName, StoreClass] of Object.entries(stores)) {
// @ts-expect-error as any casting to avoid overly complex or circular types
this.stores[storeName] =
typeof StoreClass === 'object'
? StoreClass
: (new (StoreClass as any)(
this.#config,
this.clients,
this.stores,
) as any);
// @ts-expect-error implicit any casting to avoid overly complex or circular types
storesContainers[storeName] = this.stores[storeName];
}
for (const [storeName, StoreClass] of Object.entries(
extensionStore.stores,
)) {
// @ts-expect-error as any casting to avoid overly complex or circular types
this.stores[storeName] =
typeof StoreClass === 'object'
? StoreClass
: (new (StoreClass as any)(
this.#config,
this.clients,
this.stores,
) as any);
// @ts-expect-error implicit any casting to avoid overly complex or circular types
storesContainers[storeName] = this.stores[storeName];
}
this.services = {} as typeof this.services;
for (const [serviceName, ServiceClass] of Object.entries(services)) {
// @ts-expect-error as any casting to avoid overly complex or circular types
this.services[serviceName] =
typeof ServiceClass === 'object'
? ServiceClass
: (new (ServiceClass as any)(
this.#config,
this.clients,
this.stores,
this.services,
) as any);
// @ts-expect-error implicit any casting to avoid overly complex or circular types
servicesContainers[serviceName] = this.services[serviceName];
}
for (const [serviceName, ServiceClass] of Object.entries(
extensionStore.services,
)) {
// @ts-expect-error as any casting to avoid overly complex or circular types
this.services[serviceName] =
typeof ServiceClass === 'object'
? ServiceClass
: (new (ServiceClass as any)(
this.#config,
this.clients,
this.stores,
this.services,
) as any);
// @ts-expect-error implicit any casting to avoid overly complex or circular types
servicesContainers[serviceName] = this.services[serviceName];
}
// Wire the rate-limiter to its configured backend now that clients
// and stores exist. Memory is the default; `redis` needs a redis
// client, `kv` needs the system KV store (DynamoDB-backed).
this.#configureRateLimiter();
// Anti-CSRF tokens live in redis so they survive cross-node hops
// (issue on node A, consume on node B).
setAntiCsrfRedis(this.clients.redis);
setCaptchaRedis(this.clients.redis);
// init express server here
this.#app = express();
// `trust proxy` MUST be set before any middleware reads `req.ip` /
// `req.ips` / `req.protocol`, since express derives those from XFF
// only when this flag is set. Default is `false` (no proxy trusted)
// — deployments behind a reverse proxy chain must set
// `config.trust_proxy` to the hop count (e.g. `1` for a single
// Cloudflare/nginx hop). Never `true` in prod: that trusts every hop
// and makes XFF forgeable.
this.#app.set('trust proxy', this.#config.trust_proxy ?? false);
this.#installGlobalMiddleware();
// Instantiate drivers BEFORE controllers so controllers can receive
// a typed `drivers` reference. The `/drivers/*` HTTP surface lives
// on `DriverController` (a regular controller) which reads from
// `this.drivers` — no separate registry object here any more.
this.drivers = {} as typeof this.drivers;
const allDriverSources = [
...Object.entries(drivers),
...Object.entries(extensionStore.drivers),
];
for (const [driverKey, DriverClass] of allDriverSources) {
const instance =
typeof DriverClass === 'object'
? DriverClass
: (new (DriverClass as any)(
this.#config,
this.clients,
this.stores,
this.services,
) as any);
// @ts-expect-error as any casting to avoid overly complex or circular types
this.drivers[driverKey] = instance;
driversContainers[driverKey] = instance;
}
this.controllers = {} as typeof this.controllers;
for (const [controllerName, ControllerClass] of Object.entries(
controllers,
)) {
// @ts-expect-error as any casting to avoid overly complex or circular types
this.controllers[controllerName] =
typeof ControllerClass === 'object'
? ControllerClass
: (new (ControllerClass as any)(
this.#config,
this.clients,
this.stores,
this.services,
this.drivers,
) as any);
this.#registerControllerRoutes(
controllerName,
// @ts-expect-error as any casting to avoid overly complex or circular types
this.controllers[controllerName],
);
controllersContainers[controllerName] =
// @ts-expect-error as any casting to avoid overly complex or circular types
this.controllers[controllerName];
}
for (const [controllerName, ControllerClass] of Object.entries(
extensionStore.controllers,
)) {
// @ts-expect-error as any casting to avoid overly complex or circular types
this.controllers[controllerName] =
typeof ControllerClass === 'object'
? ControllerClass
: (new (ControllerClass as any)(
this.#config,
this.clients,
this.stores,
this.services,
this.drivers,
) as any);
this.#registerControllerRoutes(
controllerName,
// @ts-expect-error as any casting to avoid overly complex or circular types
this.controllers[controllerName],
);
controllersContainers[controllerName] =
// @ts-expect-error as any casting to avoid overly complex or circular types
this.controllers[controllerName];
}
// Extension routes are shaped as `RouteDescriptor`s too, so they
// flow through the same materializer as controller routes — same
// option → middleware translation (subdomain, auth, body parsers, …).
// The extension-layer "prefix" is always empty; extensions compose
// their own path strings.
for (const route of extensionStore.routeHandlers) {
this.#materializeRoute(this.#app, '', route);
}
// Terminal middleware MUST install last — after every route + extension
// route is registered, so the catch-all 404 only fires for genuinely
// unmatched requests, and the error handler is reachable from any
// thrown error in the stack above it.
this.#installTerminalMiddleware();
return true;
}
/**
* Register every rate-limit backend whose dependency is available, so
* routes / drivers can mix and match per call. `config.rate_limit.backend`
* selects the _default_ applied when a caller doesn't specify a backend;
* it's no longer an exclusive choice. A typo or missing dependency for the
* chosen default falls back to memory with a warning so boot doesn't
* break.
*/
#configureRateLimiter() {
// Default to `redis` — the redis client is always present (falls
// back to ioredis-mock in dev when no nodes are configured), and
// sorted-set rate limiting scales across nodes for free. Set
// `rate_limit.backend` in config to switch to `memory` or `kv`.
const defaultBackend = this.#config.rate_limit?.backend ?? 'redis';
// Metering is wired so the concurrency gate can resolve
// `bySubscription` overrides per actor. Optional — without it,
// the base `limit` applies uniformly.
const metering = this.services?.metering as unknown;
try {
configureRateLimit({
default: defaultBackend,
redis: this.clients.redis,
kv: this.stores.kv,
metering,
});
} catch (e) {
console.warn(
`[rate-limit] default backend '${defaultBackend}' unavailable, falling back to memory:`,
(e as Error).message,
);
configureRateLimit({
redis: this.clients.redis,
kv: this.stores.kv,
metering,
});
}
}
/**
* Install always-on middleware on the express app, in the order they must
* run at request time. Ordering note:
*
* - `express.json` must run before `authProbe` so `req.body.auth_token` is
* readable.
* - `authProbe` never rejects; it only populates `req.actor` if a valid token
* is present.
* - Per-route gate middleware (requireAuth, adminOnly, ...) lands in
* `#materializeRoute` as those options ship.
*/
#installGlobalMiddleware() {
// -- Egress metering -----------------------------------------
// First, so the byte counter wraps `res.write` before compression
// does and therefore counts what actually goes out rather than what
// the handler produced. Reads the actor when the response ends, by
// which point the auth probe below has run.
this.#app.use(
createEgressMeteringMiddleware({ services: this.services }),
);
this.#app.use(cookieParser());
this.#app.use(compression());
this.#app.use(helmet.noSniff());
this.#app.use(helmet.hsts());
this.#app.use(helmet.ieNoOpen());
this.#app.use(helmet.permittedCrossDomainPolicies());
this.#app.use(helmet.xssFilter());
// Don't leak full URLs (which can carry signed tokens / file paths)
// to cross-origin destinations. Per-user hosted sites tighten this
// further to `no-referrer` in the puterSite middleware.
this.#app.use(
helmet.referrerPolicy({
policy: 'strict-origin-when-cross-origin',
}),
);
this.#app.disable('x-powered-by');
// Cross-Origin-Resource-Policy: always allow cross-origin reads.
// The stricter COOP+COEP pair (for SharedArrayBuffer) is deferred
// until the hosting layer lands — it requires UA + context gating.
this.#app.use((_req, res, next) => {
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
next();
});
// -- Query param sanitization --------------------------------
// Strip non-primitive query values. Express 5's default simple
// parser mostly avoids these, but when `extended` qs is enabled
// (or a client tricks the parser) arrays/objects can sneak in.
this.#app.use((req, _res, next) => {
if (req.query) {
const allowed = ['string', 'number', 'boolean'];
for (const k of Object.keys(req.query)) {
const v = req.query[k];
if (v != null && !allowed.includes(typeof v)) {
delete req.query[k];
}
}
}
next();
});
// -- UA parsing ----------------------------------------------
this.#app.use((req, _res, next) => {
const header = req.headers['user-agent'];
if (header) {
req.ua = uaParser(header);
}
next();
});
// -- Host header validation ----------------------------------
this.#installHostValidation();
// -- Host redirects (www → root, user subdomain → static hosting)
// Installed after host validation so we know the host is allowed,
// and before CORS/body-parsing so we short-circuit on redirects
// without burning work.
this.#app.use(createWwwRedirect(this.#config));
this.#app.use(createUserSubdomainRedirect(this.#config));
// -- Native app static serving (editor.*, docs.*, …) ---------
// No-op when `native_apps_root` is unset.
this.#app.use(createNativeAppStatic(this.#config));
// -- CORS headers --------------------------------------------
this.#installCors();
// -- IP validation -------------------------------------------
if (this.#config.enable_ip_validation) {
this.#installIpValidation();
}
// -- OPTIONS preflight ---------------------------------------
this.#app.options('/*splat', (_req, res) => {
res.sendStatus(200);
});
// -- Local Worker proxy (*.workers.puter.localhost) ----------
// Dev-only Miniflare dispatch, gated on `config.workers.localServer`.
// Mounted BEFORE body parsing so the Worker receives the raw request
// stream; no-op in production (real Cloudflare via WorkerDriver).
this.#app.use(
createLocalWorkerProxyMiddleware(this.#config, {
clients: this.clients,
stores: this.stores,
services: this.services,
}),
);
// -- Body parsing (JSON + text-as-json shim) -----------------
const captureRawBody: NonNullable<
Parameters[0]
>['verify'] = (req, _res, buf) => {
(req as { rawBody?: Buffer }).rawBody = Buffer.from(buf);
};
this.#app.use(express.json({ limit: '50mb', verify: captureRawBody }));
this.#app.use(
express.json({
limit: '50mb',
type: (req) =>
req.headers['content-type'] === 'text/plain;actually=json',
verify: captureRawBody,
}),
);
// Form-encoded bodies (e.g. `/down` from the GUI's iframe-triggered
// download form). Needs to run before the auth probe so
// `req.body.auth_token` is populated for urlencoded POSTs the same
// way it is for JSON POSTs. Small cap — this parser is only here to
// cover the auth_token / anti_csrf field shape, not file uploads.
this.#app.use(express.urlencoded({ extended: true, limit: '100kb' }));
// -- Auth probe ----------------------------------------------
const authService = this.services.auth as AuthService | undefined;
if (authService) {
this.#app.use(
createAuthProbe({
authService,
cookieName: this.#config.cookie_name,
}),
);
}
// -- Request fingerprints ------------------------------------
// Stamp `req.networkFingerprint` (server-derived) and
// `req.deviceFingerprint` (client-supplied, from body/header). Runs
// AFTER body parsing so the body fingerprint is readable, and before
// the ALS context so the snapshot carries them.
this.#app.use(createFingerprintMiddleware());
// -- Per-request ALS context ---------------------------------
// Runs AFTER auth probe so `req.actor` is already populated when
// we snapshot it into the context.
this.#app.use(createRequestContextMiddleware());
// -- User-hosted sites (*.puter.site, *.puter.app) -----------
// Short-circuits hosting-domain hosts before any API/GUI
// controller route has a chance to match. Needs DI layers for
// subdomain lookup, private-app gate, and file streaming.
this.#app.use(
createPuterSiteMiddleware(this.#config, {
clients: this.clients,
stores: this.stores,
services: this.services,
}),
);
extensionStore.globalMiddlewares.forEach((mw) => {
this.#app.use(mw);
});
}
// -- Host header validation --------------------------------------
#installHostValidation() {
const config = this.#config;
// Hostname missing — malformed request from a broken client.
this.#app.use((req, res, next) => {
if (req.hostname === undefined) {
res.status(400).send(
'Please verify your browser is up-to-date.',
);
return;
}
next();
});
// Build the allowed-domain set from config.
this.#app.use((req, res, next) => {
if (config.allow_all_host_values) {
next();
return;
}
if (!config.allow_no_host_header && !req.headers.host) {
res.status(400).send('Missing Host header.');
return;
}
// /healthcheck is always reachable regardless of host.
if (req.path === '/healthcheck') {
next();
return;
}
const hostName = (req.headers.host ?? '')
.split(':')[0]
.trim()
.toLowerCase();
const allowed = this.#getAllowedDomains();
if (
allowed.some((d) => PuterServer.#hostMatchesDomain(hostName, d))
) {
next();
return;
}
if (config.custom_domains_enabled) {
req.is_custom_domain = true;
next();
return;
}
res.status(400).send('Invalid Host header.');
});
}
#allowedDomainsCache: string[] | null = null;
#getAllowedDomains(): string[] {
if (this.#allowedDomainsCache) return this.#allowedDomainsCache;
const cfg = this.#config;
const raw = [
cfg.domain,
cfg.static_hosting_domain,
cfg.static_hosting_domain_alt,
cfg.private_app_hosting_domain,
cfg.private_app_hosting_domain_alt,
];
const staticDomain = PuterServer.#normalizeDomain(
cfg.static_hosting_domain,
);
if (staticDomain) raw.push(`at.${staticDomain}`);
if (cfg.allow_nipio_domains) raw.push('nip.io');
this.#allowedDomainsCache = raw
.map(PuterServer.#normalizeDomain)
.filter((d): d is string => d !== null);
return this.#allowedDomainsCache;
}
static #normalizeDomain(d: string | undefined | null): string | null {
if (!d || typeof d !== 'string') return null;
const trimmed = d.trim().toLowerCase();
return trimmed.length > 0 ? trimmed : null;
}
static #hostMatchesDomain(hostname: string, domain: string): boolean {
return hostname === domain || hostname.endsWith(`.${domain}`);
}
// -- CORS headers -------------------------------------------------
#installCors() {
const config = this.#config;
const allowedMethods =
'GET, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK';
const allowedHeaders = [
'Origin',
'X-Requested-With',
'Content-Type',
'Accept',
'Authorization',
'Cache-Control',
'Pragma',
'sentry-trace',
'baggage',
'Depth',
'Destination',
'Overwrite',
'If',
'Lock-Token',
'DAV',
'stripe-signature',
].join(', ');
this.#app.use((req, res, next) => {
const origin = req.headers.origin;
const subdomain = req.subdomains?.[req.subdomains.length - 1];
// Allow any origin. puter.js is meant to be consumed from
// arbitrary third-party sites, so reflect the caller's origin
// (or fall back to `*` for non-browser clients).
res.setHeader('Access-Control-Allow-Origin', origin ?? '*');
if (origin) res.vary('Origin');
// Sticky cookies require api to allow credentials, but only for the API subdomain, and be careful not to set any other credentials on it
if (subdomain === 'api' && origin) {
res.setHeader('Access-Control-Allow-Credentials', 'true');
} else if (subdomain === 'dav') {
res.setHeader('Access-Control-Allow-Credentials', 'false');
}
res.setHeader('Access-Control-Allow-Methods', allowedMethods);
res.setHeader('Access-Control-Allow-Headers', allowedHeaders);
res.setHeader('Access-Control-Allow-Private-Network', 'true');
// Disable iframes on the main domain
if (req.hostname === config.domain) {
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
}
next();
});
}
// -- IP validation -----------------------------------------------
#installIpValidation() {
this.#app.use(async (req, res, next) => {
// `req.ip` reflects `trust proxy`: it's the leftmost untrusted
// address from XFF when behind a configured proxy chain, and the
// direct socket peer otherwise. Reading XFF directly would let a
// client forge the value when traffic isn't behind the expected
// proxy.
const ip = req.ip;
const event = { allow: true, ip: ip! };
// emitAndWait so listeners that do async work (IP-reputation
// lookups, Redis checks) can complete before we read
// `event.allow` and decide the gate.
await this.clients.event.emitAndWait('ip.validate', event, {});
if (!event.allow) {
res.status(403).send('Forbidden');
return;
}
next();
});
}
/**
* Install end-of-pipeline middleware. Order matters:
*
* 1. The 404 catch-all runs only when no earlier route matched, so it must be
* installed _after_ every controller + extension route.
* 2. The error handler is the express terminal — it catches everything thrown
* by routes, gates, and the 404 above. Express 5 auto-forwards thrown
* errors (sync and async), so handlers can `throw new HttpError(...)`
* without `next(err)` ceremony.
*/
#installTerminalMiddleware() {
this.#app.use(
createNotFoundHandler({ guiDomain: this.#config.domain }),
);
this.#app.use(
createErrorHandler({
onError: (err, req) => {
// Page on 5xx only — skip 4xx HttpErrors, which are
// expected client-caused failures. Non-HttpError values
// are treated as unexpected 500s. De-dupe alarms by
// route + error signature so a hot loop of the same
// crash lands as a single alarm with N occurrences
// instead of N pages.
//
// FORCED_ALERT_CODES override the 5xx-only rule: a
// status < 500 still alarms if its legacyCode is in
// the map. Use this for things we want to know about
// even though we expose them as 4xx to users (e.g.
// sustained upstream provider rate limits). They are
// not our own crashes, so each maps to the severity it
// deserves rather than paging.
//
// SKIP_ALERT_PREFIXES override the 5xx rule the other
// direction: an error tagged as caused by an upstream
// provider or a misbehaving client gets exposed to
// the user but does not alarm at all.
//
// `noAlarm` beats both: the call site already decided
// this failure isn't worth recording (e.g. an upstream
// rate limit on a free model, where the volume tracks
// traffic and there's nothing to act on).
const FORCED_ALERT_CODES = new Map([
['upstream_rate_limited', 'info'],
// Our credentials for a provider stopped working —
// everything through it fails until someone looks.
['upstream_auth_failed', 'warning'],
]);
const SKIP_ALERT_PREFIXES = /^(upstream_|client_)/;
const isHttp = isHttpError(err);
if (isHttp && err.noAlarm) return;
const status = isHttp ? err.statusCode : 500;
const legacyCode = isHttp ? (err.legacyCode ?? '') : '';
const forcedSeverity = FORCED_ALERT_CODES.get(legacyCode);
if (!forcedSeverity) {
if (status < 500) return;
if (SKIP_ALERT_PREFIXES.test(legacyCode)) return;
}
const signature = !isHttp
? err instanceof Error
? err.message
: String(err)
: status >= 500
? `${err.legacyCode || err.code || 'http'}:${err.message}`
: err.legacyCode || err.code || err.message;
const routePath =
(req as unknown as { route?: { path?: string } }).route
?.path ?? req.path;
const alarmId = `http_${status}:${req.method}:${routePath}:${signature}`;
this.clients.alarm.create(
alarmId,
`HTTP ${status} on ${req.method} ${req.originalUrl}: ${signature}`,
{
error: err instanceof Error ? err : undefined,
status,
method: req.method,
path: req.originalUrl,
body: req.body,
route: routePath,
actor: req.actor,
},
// An unhandled server error is the one thing that
// still pages on-call.
forcedSeverity ?? 'critical',
// The id pins route + error signature, so repeats are
// the same fault and belong on one incident.
{ dedup: true },
);
},
}),
);
}
/**
* Walk a controller's declared routes (via `PuterRouter`) and register each
* one against the underlying express app. Per-route option → middleware
* translation lives here — when we add auth/subdomain/body-parsing options,
* they get wired in at this single point without touching any controller
* call site.
*/
#registerControllerRoutes(
controllerName: string,
controller: WithControllerRegistration,
) {
if (!controller.registerRoutes) {
throw new Error(
`Controller ${controllerName} does not have registerRoutes method`,
);
}
// Controllers annotated with `@Controller('/prefix')` carry the prefix
// on their prototype; bare (imperative) controllers default to ''.
const prefix = (controller as unknown as Record)[
PREFIX_METADATA_KEY
] as string | undefined;
const router = new PuterRouter(prefix ?? '');
controller.registerRoutes(router);
for (const route of router.routes) {
this.#materializeRoute(this.#app, router.prefix, route);
}
}
#materializeRoute(
app: Application,
routerPrefix: string,
route: RouteDescriptor,
) {
const mwChain: RequestHandler[] = [];
const opts = route.options;
// Validated here rather than trusted, and by the same function the
// driver decorator uses: a malformed requirement is a boot failure
// naming the route, never a gate that quietly admits everyone.
const subscriptionRequirement =
opts.requireSubscription === undefined
? undefined
: validateSubscriptionRequirement(
opts.requireSubscription,
`route ${route.method.toUpperCase()} ${routerPrefix}${String(route.path)}: requireSubscription`,
);
const requiresSubscription =
subscriptionRequirement !== undefined &&
subscriptionRequirement !== false;
// 1. Subdomain routing. Routes that specify `subdomain` only match
// that subdomain(s). Routes WITHOUT a `subdomain` option (and that
// aren't `use` middleware) are restricted to the root origin — this
// prevents API-subdomain requests from accidentally hitting a root-
// only route. Explicit `subdomain: '*'` disables the gate entirely.
//
// For `use` routes, `next('route')` in a middleware doesn't skip the
// handler (that's only reliable inside `app.METHOD`/`router.METHOD`
// chains). We handle subdomain gating by wrapping the handler for
// `use` routes further down — don't push `subdomainGate` here.
const isUse = route.method === 'use';
if (opts.subdomain !== undefined) {
if (opts.subdomain !== '*' && !isUse) {
mwChain.push(subdomainGate(opts.subdomain));
}
// subdomain: '*' → no gate, match any subdomain
} else if (!isUse) {
// No subdomain specified + not a `use()` middleware → root only.
// Root = no subdomain present (req.subdomains is empty).
mwChain.push((req, _res, next) => {
if (req.subdomains && req.subdomains.length > 0) {
next('route');
return;
}
next();
});
}
// 1b. Origin gate. Runs before auth, rate limiting, and captcha so an
// off-origin caller is rejected on the header alone — it never reaches
// the credential comparison, and it can't burn another request's rate
// limit budget on the way. Unauthenticated by nature: the routes that
// opt in are the ones that *hand out* a credential.
if (opts.guiOriginOnly) {
mwChain.push(guiOriginGate(this.#config));
}
// 2. Auth gates. Implication graph:
// adminOnly => requireAuth
// allowedAppIds => requireAuth
// requireUserActor => requireAuth
// Dedupe: only push requireAuthGate once when *any* of these are set.
const needsAuth = Boolean(
opts.requireAuth ||
opts.requireUserActor ||
opts.adminOnly ||
opts.allowedAppIds ||
opts.requireVerified ||
opts.noUserSession ||
opts.requirePhoneVerified ||
opts.requireCardVerified ||
requiresSubscription,
);
if (needsAuth) {
mwChain.push(requireAuthGate());
}
// Default-on account-verification gate. Every authenticated route
// rejects accounts still pending any signup-time verification —
// email confirmation, SMS phone verification, or card verification —
// unless `allowUnconfirmed` opts out. This is what keeps low-reputation
// signups (which the abuse harness flags instead of hard-blocking) out
// of AI, FS, driver, etc. endpoints server-side, not just behind the
// GUI modal, while still allowing essential flows (logout,
// confirm-email / -phone, card verification, whoami, save-account, …).
if (needsAuth && !opts.allowUnconfirmed) {
mwChain.push(requireVerifiedAccount());
}
// block access tokens by default
if (needsAuth && !opts.allowAccessToken) {
mwChain.push(requireNonAccessTokenGate());
}
// `requireVerified` intentionally does NOT imply `requireUserActor`:
// FS routes (and similar) want the user's email to be confirmed even
// when an app acts on the user's behalf. `requireVerifiedGate` reads
// `req.actor?.user?.email_confirmed`, which app-under-user actors
// carry, so it works for either actor shape.
//
// `adminOnly` also does NOT imply `requireUserActor`: admin endpoints
// stay callable from scripts/automation using an admin's full-access
// token, not only from browser sessions — both are root tokens.
// Beyond the username check, `adminOnlyGate` requires a root token
// (rejecting an admin acting through a third-party app) unless the
// route is also appId-gated, in which case `allowedAppIdsGate` governs
// which apps may pass.
if (opts.requireUserActor) {
mwChain.push(
requireUserActorGate({
allowFullAccess: opts.allowFullAccessToken,
}),
);
}
// Bare user-session ("root" token) rejection. Runs after
// `requireUserActor` so that on routes combining both, an app is
// rejected with the user-actor message and only a bare session gets
// the "use an app or API token" message.
if (opts.noUserSession) {
mwChain.push(noUserSessionGate());
}
if (opts.adminOnly) {
const extras = Array.isArray(opts.adminOnly) ? opts.adminOnly : [];
mwChain.push(
adminOnlyGate(extras, {
appGated: Boolean(opts.allowedAppIds),
}),
);
// An admin username on a leaked session isn't enough — also require
// a recent re-authentication. Exempt only a token that carries one
// of the route's allowlisted app ids: an admin acting through an
// allowlisted app can't elevate (apps have no password/TOTP; see
// createStepUpGate). A root/human session — no app id in the token —
// still requires step-up, and `allowedAppIdsGate` still enforces the
// allowlist for the app path.
mwChain.push(
createStepUpGate({
tokenService: this.services.token,
allowedAppUids: opts.allowedAppIds,
}),
);
}
if (opts.allowedAppIds) {
mwChain.push(allowedAppIdsGate(opts.allowedAppIds));
}
// 2a. Email verification. Keyed off `strict_email_verification_required`
// so self-hosted boxes without SMTP don't break every fs route.
if (opts.requireVerified) {
mwChain.push(
requireVerifiedGate(
Boolean(this.#config.strict_email_verification_required),
),
);
}
// 2a'. Per-factor verification gates. Opt-in, and independent of the
// default-on pending-verification gate above: these require the factor
// to have actually been verified, for routes worth that friction.
if (opts.requirePhoneVerified) {
mwChain.push(requirePhoneVerifiedGate());
}
if (opts.requireCardVerified) {
mwChain.push(requireCardVerifiedGate());
}
// 2a''. Plan enforcement. Before the rate limit — the same order the
// driver dispatch path uses — so an account on a plan that never
// included this route is told to upgrade rather than to slow down,
// and doesn't spend a rate-limit token on a request that can never
// pass. Also before the budget gate: "your plan doesn't include this"
// beats "you're out of credits" that a top-up wouldn't fix.
if (requiresSubscription) {
mwChain.push(
requireSubscriptionGate(
this.services.metering,
this.#config,
subscriptionRequirement!,
),
);
}
// 2b. Rate limiting. Runs after auth so 'user' key strategy
// has access to req.actor. An array applies each limit as its
// own gate — a request must pass all of them.
if (opts.rateLimit) {
const limits = Array.isArray(opts.rateLimit)
? opts.rateLimit
: [opts.rateLimit];
for (const rl of limits) {
mwChain.push(rateLimitGate(rl) as unknown as RequestHandler);
}
}
// 2b''. Budget enforcement. After the rate limit so a caller over
// both gets the cheaper, more specific answer, and before the
// concurrency slot so a rejected request never takes one. Answered
// from the metering service's per-actor cache, so ordering it here
// costs a map lookup rather than a store read.
if (opts.requireCredits) {
mwChain.push(
requireCreditsGate(this.services.metering, this.#config),
);
}
// 2b'. Concurrent in-flight limiting. Same auth-ordering reason
// (user key + bySubscription resolution needs req.actor); installed
// after rateLimitGate so a rate-rejection short-circuits before
// we acquire a concurrency slot. Slot is released on res finish/close.
if (opts.concurrent) {
mwChain.push(
concurrencyGate(opts.concurrent) as unknown as RequestHandler,
);
}
// 2c. Captcha verification. Reads captchaToken + captchaAnswer
// from req.body — body is already parsed by the global JSON
// middleware at this point.
if (opts.captcha) {
const enabled = Boolean(this.#config.captcha?.enabled);
mwChain.push(captchaGate(enabled) as unknown as RequestHandler);
}
// 2d. Anti-CSRF token consumption.
if (opts.antiCsrf) {
mwChain.push(requireAntiCsrf() as unknown as RequestHandler);
}
// 3. Per-route body parsers. Each is a no-op when the request's
// content-type doesn't match — multiple can coexist. The global
// `application/json` parser already ran in `#installGlobalMiddleware`,
// so by default the only reason to opt into one of these is to handle
// a non-JSON body shape (raw bytes, plain text, urlencoded form) or
// to override JSON limits on a hot path.
// bodyJson is `false | { limit?, type? }`. Truthiness check excludes
// both `undefined` (no opt) and `false` (explicit opt-out).
if (opts.bodyJson) {
mwChain.push(
express.json({
limit: opts.bodyJson.limit,
type: opts.bodyJson.type,
}),
);
}
if (opts.bodyRaw) {
const raw = opts.bodyRaw === true ? {} : opts.bodyRaw;
mwChain.push(
express.raw({
limit: raw.limit,
type: raw.type,
}),
);
}
if (opts.bodyText) {
const text = opts.bodyText === true ? {} : opts.bodyText;
mwChain.push(
express.text({
limit: text.limit,
type: text.type,
}),
);
}
if (opts.bodyUrlencoded) {
const ue = opts.bodyUrlencoded === true ? {} : opts.bodyUrlencoded;
mwChain.push(
express.urlencoded({
limit: ue.limit,
extended: ue.extended ?? true,
}),
);
}
// 4. Caller-supplied middleware runs after gates + parsers, before the handler.
if (opts.middleware) mwChain.push(...opts.middleware);
const fullPath =
route.path !== undefined
? PuterServer.#joinPath(routerPrefix, route.path)
: undefined;
// 5. Per-endpoint lifecycle events. Skipped for `use` middleware
// (those aren't endpoints). Pushed last so the `before` hook sees a
// fully-authenticated request, and only when the event client is
// wired (minimal test harnesses may omit it).
if (route.method !== 'use' && this.clients.event) {
mwChain.push(
createRouteLifecycleMiddleware(
this.clients.event,
route.method,
fullPath,
),
);
}
if (route.method === 'use') {
// Subdomain check for `use` middleware lives INSIDE the handler
// wrapper — `next('route')` from a stand-alone subdomainGate
// doesn't reliably skip a `use` handler in Express 5.
let handler = route.handler;
if (opts.subdomain !== undefined && opts.subdomain !== '*') {
const allowList = Array.isArray(opts.subdomain)
? opts.subdomain
: [opts.subdomain];
const original = handler;
handler = (req, res, next) => {
const active =
req.subdomains?.[req.subdomains.length - 1] ?? '';
if (!allowList.includes(active)) return next();
return original(req, res, next);
};
}
if (fullPath !== undefined) {
app.use(fullPath as any, ...mwChain.flat(), handler);
} else {
app.use(...mwChain.flat(), handler);
}
return;
}
if (fullPath === undefined) {
throw new Error(`Route method '${route.method}' requires a path`);
}
// All express + WebDAV verbs accept the same (path, ...handlers) shape.
// The `RouteMethod` union is the allowlist of method names we expose.
const method = app[route.method as keyof Application] as unknown;
if (typeof method !== 'function') {
throw new Error(
`Express app does not support method: ${route.method}`,
);
}
(method as (...args: unknown[]) => unknown).call(
app,
fullPath,
...mwChain,
route.handler,
);
}
/**
* Join a controller's prefix with a route path. RegExp / array paths are
* passed through unprefixed (consistent with express's behavior; decorator
* paths are assumed to be strings).
*/
static #joinPath(
prefix: string,
path: NonNullable,
): string | RegExp | Array {
if (typeof path !== 'string') return path;
if (!prefix) return path;
return `${prefix}/${path}`.replace(/\/+/g, '/');
}
async #importExtensions(extensionDirs: string[]) {
for (const extDir of extensionDirs) {
// `withFileTypes: true` gives us `Dirent` objects so we can
// distinguish files from directories without extra stat calls
// (and without relying on a dot-in-name heuristic, which breaks
// for data-bearing sidecar dirs like `pages.assets/`).
for (const entry of readdirSync(extDir, { withFileTypes: true })) {
const entryPath = `${extDir}/${entry.name}`;
if (entry.isFile()) {
const name = entry.name;
let shouldImport: boolean;
if (this.#config.import_ts_extensions) {
// Extensions ship as compiled .js at runtime, but
// transform-capable runtimes (the test harness)
// import the .ts sources directly. Skip tests and
// declarations, and skip built .js siblings of a
// .ts source so a previously-built tree doesn't
// double-register.
if (name.endsWith('.ts')) {
shouldImport =
!name.endsWith('.test.ts') &&
!name.endsWith('.d.ts');
} else {
shouldImport =
/\.(js|mjs|cjs)$/.test(name) &&
!/\.test\.(js|mjs|cjs)$/.test(name) &&
!existsSync(
entryPath.replace(/\.(js|mjs|cjs)$/, '.ts'),
);
}
} else {
shouldImport = /\.(js|mjs|cjs)$/.test(name);
}
if (shouldImport) {
console.log(`Importing extension file ${entryPath}`);
await import(pathToFileURL(entryPath).href);
}
continue;
}
if (!entry.isDirectory()) continue; // symlinks, etc. — skip
// Prefer package.json "main"; fall back to index.{js,mjs,cjs}.
// Dirs that match neither (e.g. data-only sidecars) are
// silently ignored rather than crashing the boot.
let mainPath: string | null = null;
const pkgPath = `${entryPath}/package.json`;
if (existsSync(pkgPath)) {
try {
const pkg = JSON.parse(
readFileSync(pkgPath, 'utf-8'),
) as { main?: string };
if (pkg.main) mainPath = `${entryPath}/${pkg.main}`;
} catch (e) {
console.warn(
`[extensions] invalid package.json at ${pkgPath}:`,
e,
);
continue;
}
}
if (!mainPath) {
for (const cand of ['index.js', 'index.mjs', 'index.cjs']) {
if (existsSync(`${entryPath}/${cand}`)) {
mainPath = `${entryPath}/${cand}`;
break;
}
}
}
if (!mainPath) continue;
console.log(`Importing extension file ${mainPath}`);
await import(pathToFileURL(mainPath).href);
}
}
}
async start(noHttpServer = false) {
await this.#ready;
// Installed before anything starts serving, so a fault during boot is
// reported too. Logging is unconditional; whether an uncaught exception
// ends the process is a deployment decision, hence the config gate.
this.#removeProcessGuards = installProcessGuards({
keepAliveOnUncaught: this.#config.keep_alive_on_uncaught ?? false,
});
// Create the http server explicitly (instead of `app.listen()`) so we
// have the server reference BEFORE listen starts — anything that needs
// to hook into the raw server (socket.io upgrades, WebSockets, …) runs
// its `attachHttpServer(server)` here, pre-listen.
const httpServer = http.createServer(this.#app);
for (const service of Object.values(this.services) as Array<
WithLifecycle & {
attachHttpServer?: (s: http.Server) => void | Promise;
}
>) {
if (typeof service.attachHttpServer === 'function') {
await service.attachHttpServer(httpServer);
}
}
if (!noHttpServer) {
// Await 'listening' (and full boot below) so callers can rely on
// the server being reachable once start() resolves — test
// harnesses connect real clients immediately after.
this.#server = httpServer.listen(this.#config.port);
await new Promise((resolve, reject) => {
const onError = (err: Error) => reject(err);
httpServer.once('error', onError);
httpServer.once('listening', () => {
// Detach so post-boot 'error' events aren't swallowed
// by a no-op reject on this settled promise.
httpServer.removeListener('error', onError);
resolve();
});
});
const cfg = this.#config;
const liveUrl =
cfg.origin ??
`${cfg.protocol ?? 'http'}://${cfg.domain ?? 'localhost'}:${this.#config.port}`;
console.log(
'\n************************************************************',
);
console.log(`* Puter is now live at: ${liveUrl}`);
console.log(
'************************************************************\n',
);
await this.#fireOnServerStart();
console.log('PuterServer has fully booted.');
// CLI: `--server` (optionally `--puter-backend=`)
// runs the AuthMe flow against a remote Puter (default
// puter.com), then opens the local GUI already logged in and
// pointed at that backend. Restores the v1 WebServerService
// `--server` behavior; works in any env. When set, it takes
// over browser launch so we don't also open a plain tab.
const { values: cliArgs } = parseArgs({
args: process.argv.slice(2),
options: {
server: { type: 'boolean' },
'puter-backend': { type: 'string' },
},
strict: false,
});
if (cliArgs.server) {
try {
// tools/auth_gui.js is not compiled into dist/, so
// resolve it from the package root (cwd, per the
// `start` script) rather than relative to this module.
const authGuiUrl = pathToFileURL(
path.resolve(process.cwd(), 'tools/auth_gui.js'),
).href;
const authGui = (await import(authGuiUrl)).default;
await authGui(
cliArgs['puter-backend'] as string | undefined,
);
} catch (e) {
console.log(
'[server] could not start AuthMe browser flow:',
(e as Error).message,
);
}
} else if (this.#config.env === 'dev' && !cfg.no_browser_launch) {
// Auto-launch the browser on dev boot (matches v1
// WebServerService). Opt out via `no_browser_launch: true`.
try {
const openModule = await import('open');
await openModule.default(liveUrl);
} catch (e) {
console.log(
'[server] could not auto-open browser:',
(e as Error).message,
);
}
}
} else {
this.#server = {
close: (cb: (error?: Error) => void | undefined) => {
console.debug('PuterServer mock close called');
cb?.();
},
closeAllConnections: () => {
console.debug(
'PuterServer mock closeAllConnections called',
);
},
} as unknown as http.Server;
// Tests still need onServerStart to fire so stores can
// bootstrap (e.g. SystemKVStore creates its dynalite table).
await this.#fireOnServerStart();
}
}
async #fireOnServerStart() {
for (const client of Object.values(this.clients) as WithLifecycle[]) {
if (client.onServerStart) await client.onServerStart();
}
for (const store of Object.values(this.stores) as WithLifecycle[]) {
if (store.onServerStart) await store.onServerStart();
}
for (const service of Object.values(this.services) as WithLifecycle[]) {
if (service.onServerStart) await service.onServerStart();
}
for (const controller of Object.values(
this.controllers,
) as WithLifecycle[]) {
if (controller.onServerStart) await controller.onServerStart();
}
for (const driver of Object.values(this.drivers) as WithLifecycle[]) {
if (driver.onServerStart) await driver.onServerStart();
}
}
#prepareShutdownHooksRan = false;
/**
* Run every layer's `onServerPrepareShutdown` exactly once, whichever of
* `prepareShutdown()` / `shutdown()` gets there first.
*/
async #runPrepareShutdownHooks() {
if (this.#prepareShutdownHooksRan) return;
this.#prepareShutdownHooksRan = true;
for (const client of Object.values(this.clients) as WithLifecycle[]) {
if (client.onServerPrepareShutdown) {
await client.onServerPrepareShutdown();
}
}
for (const store of Object.values(this.stores) as WithLifecycle[]) {
if (store.onServerPrepareShutdown) {
await store.onServerPrepareShutdown();
}
}
for (const service of Object.values(this.services) as WithLifecycle[]) {
if (service.onServerPrepareShutdown) {
await service.onServerPrepareShutdown();
}
}
for (const controller of Object.values(
this.controllers,
) as WithLifecycle[]) {
if (controller.onServerPrepareShutdown) {
await controller.onServerPrepareShutdown();
}
}
for (const driver of Object.values(this.drivers) as WithLifecycle[]) {
if (driver.onServerPrepareShutdown) {
await driver.onServerPrepareShutdown();
}
}
}
async prepareShutdown() {
if (this.#server) {
this.#server.close(async () => {
console.log(
'PuterServer has stopped accepting new connections',
);
await this.#runPrepareShutdownHooks();
});
}
}
async shutdown() {
this.#removeProcessGuards?.();
this.#removeProcessGuards = null;
if (this.#server) {
console.log('PuterServer is shutting down');
// Prepare hooks come first: SocketService's hook closes
// socket.io, disconnecting upgraded websocket connections that
// `closeAllConnections()` does not cover — without this,
// `close()` waits forever on any connected socket.io client.
await this.#runPrepareShutdownHooks();
// Stop accepting new connections, then sever live ones; the
// close callback fires once the listener is fully released.
const closed = new Promise((resolve) => {
this.#server!.close(() => resolve());
});
this.#server.closeAllConnections();
await closed;
for (const client of Object.values(
this.clients,
) as WithLifecycle[]) {
if (client.onServerShutdown) {
await client.onServerShutdown();
}
}
for (const store of Object.values(this.stores) as WithLifecycle[]) {
if (store.onServerShutdown) {
await store.onServerShutdown();
}
}
for (const service of Object.values(
this.services,
) as WithLifecycle[]) {
if (service.onServerShutdown) {
await service.onServerShutdown();
}
}
for (const controller of Object.values(
this.controllers,
) as WithLifecycle[]) {
if (controller.onServerShutdown) {
await controller.onServerShutdown();
}
}
for (const driver of Object.values(
this.drivers,
) as WithLifecycle[]) {
if (driver.onServerShutdown) {
await driver.onServerShutdown();
}
}
}
}
}