mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-12 00:05:38 +00:00
fix: don't redirect puter.com (#3723)
This commit is contained in:
@@ -23,16 +23,18 @@ import { tmpdir } from 'node:os';
|
||||
import nodePath from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import type { IConfig } from '../../../types';
|
||||
import { HttpError } from '../HttpError';
|
||||
import {
|
||||
createNativeAppStatic,
|
||||
createUserSubdomainRedirect,
|
||||
createUserSubdomainNotFound,
|
||||
createWwwRedirect,
|
||||
} from './hostRedirects';
|
||||
|
||||
// ── Tiny harness ────────────────────────────────────────────────────
|
||||
//
|
||||
// Each middleware either calls next() (pass-through) or res.redirect(...).
|
||||
// Capture both so each test can assert against the outcome it cares about.
|
||||
// Each middleware calls next() (pass-through), next(err) (rejection), or
|
||||
// res.redirect(...). Capture all so each test can assert against the outcome
|
||||
// it cares about.
|
||||
|
||||
interface CapturedRes {
|
||||
redirectArgs?: unknown[];
|
||||
@@ -75,6 +77,13 @@ const run = (
|
||||
return { out, next };
|
||||
};
|
||||
|
||||
const expectNotFound = (next: ReturnType<typeof vi.fn>) => {
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
const err = next.mock.calls[0][0];
|
||||
expect(err).toBeInstanceOf(HttpError);
|
||||
expect((err as HttpError).statusCode).toBe(404);
|
||||
};
|
||||
|
||||
// ── createWwwRedirect ───────────────────────────────────────────────
|
||||
|
||||
describe('createWwwRedirect', () => {
|
||||
@@ -140,29 +149,25 @@ describe('createWwwRedirect', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── createUserSubdomainRedirect ─────────────────────────────────────
|
||||
// ── createUserSubdomainNotFound ─────────────────────────────────────
|
||||
|
||||
describe('createUserSubdomainRedirect', () => {
|
||||
describe('createUserSubdomainNotFound', () => {
|
||||
const config = {
|
||||
domain: 'puter.com',
|
||||
static_hosting_domain: 'puter.site',
|
||||
} as IConfig;
|
||||
|
||||
it('redirects user subdomain to the static hosting domain — preserves path + query', () => {
|
||||
// foo.puter.com/bar?x=1 → 302 foo.puter.site/bar?x=1
|
||||
it('404s a user subdomain on the main domain (never redirects)', () => {
|
||||
const { out, next } = run(
|
||||
createUserSubdomainRedirect(config),
|
||||
createUserSubdomainNotFound(config),
|
||||
makeReq({
|
||||
subdomains: ['com', 'puter', 'foo'],
|
||||
host: 'foo.puter.com',
|
||||
originalUrl: '/bar?x=1',
|
||||
}),
|
||||
);
|
||||
expect(out.redirectArgs).toEqual([
|
||||
302,
|
||||
'https://foo.puter.site/bar?x=1',
|
||||
]);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(out.redirectArgs).toBeUndefined();
|
||||
expectNotFound(next);
|
||||
});
|
||||
|
||||
it('passes through reserved subdomains (api, js, native apps, etc.)', () => {
|
||||
@@ -170,91 +175,82 @@ describe('createUserSubdomainRedirect', () => {
|
||||
// `puter-app-icons`, `onlyoffice`, etc. all bypass.
|
||||
for (const sub of ['api', 'js', 'docs', 'editor', 'puter-app-icons']) {
|
||||
const { out, next } = run(
|
||||
createUserSubdomainRedirect(config),
|
||||
createUserSubdomainNotFound(config),
|
||||
makeReq({
|
||||
subdomains: ['com', 'puter', sub],
|
||||
host: `${sub}.puter.com`,
|
||||
}),
|
||||
);
|
||||
expect(out.redirectArgs).toBeUndefined();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
}
|
||||
});
|
||||
|
||||
it('passes through when no subdomain is present (root)', () => {
|
||||
const { out, next } = run(
|
||||
createUserSubdomainRedirect(config),
|
||||
const { next } = run(
|
||||
createUserSubdomainNotFound(config),
|
||||
makeReq({ subdomains: [], host: 'puter.com' }),
|
||||
);
|
||||
expect(out.redirectArgs).toBeUndefined();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("passes through hosts that don't end in the configured domain (custom domains)", () => {
|
||||
const { out, next } = run(
|
||||
createUserSubdomainRedirect(config),
|
||||
const { next } = run(
|
||||
createUserSubdomainNotFound(config),
|
||||
makeReq({
|
||||
subdomains: ['com', 'example', 'foo'],
|
||||
host: 'foo.example.com',
|
||||
}),
|
||||
);
|
||||
expect(out.redirectArgs).toBeUndefined();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('returns a no-op middleware when no static_hosting_domain is configured', () => {
|
||||
// Self-hosted deployments without a separate hosting domain
|
||||
// shouldn't trip user-subdomain redirects at all.
|
||||
// Self-hosted deployments without a separate hosting domain may serve
|
||||
// sites on the main domain; don't 404 them.
|
||||
const noStatic = { domain: 'puter.com' } as IConfig;
|
||||
const { out, next } = run(
|
||||
createUserSubdomainRedirect(noStatic),
|
||||
const { next } = run(
|
||||
createUserSubdomainNotFound(noStatic),
|
||||
makeReq({
|
||||
subdomains: ['com', 'puter', 'foo'],
|
||||
host: 'foo.puter.com',
|
||||
}),
|
||||
);
|
||||
expect(out.redirectArgs).toBeUndefined();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('returns a no-op middleware when no main domain is configured', () => {
|
||||
const noDomain = { static_hosting_domain: 'puter.site' } as IConfig;
|
||||
const { out, next } = run(
|
||||
createUserSubdomainRedirect(noDomain),
|
||||
const { next } = run(
|
||||
createUserSubdomainNotFound(noDomain),
|
||||
makeReq({
|
||||
subdomains: ['com', 'puter', 'foo'],
|
||||
host: 'foo.puter.com',
|
||||
}),
|
||||
);
|
||||
expect(out.redirectArgs).toBeUndefined();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('lowercases the active subdomain when comparing against the reserved set', () => {
|
||||
// Reserved-subdomain matching must be case-insensitive — otherwise
|
||||
// a request to `API.puter.com` would accidentally redirect.
|
||||
const { out, next } = run(
|
||||
createUserSubdomainRedirect(config),
|
||||
// a request to `API.puter.com` would accidentally 404.
|
||||
const { next } = run(
|
||||
createUserSubdomainNotFound(config),
|
||||
makeReq({
|
||||
subdomains: ['com', 'puter', 'API'],
|
||||
host: 'API.puter.com',
|
||||
}),
|
||||
);
|
||||
expect(out.redirectArgs).toBeUndefined();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('preserves the port when swapping domain suffix (port baked into target)', () => {
|
||||
// The middleware does a raw `endsWith` on `host` to find the
|
||||
// domain suffix, so if production puts a port on `config.domain`,
|
||||
// it has to match exactly. Configure both with the port to
|
||||
// exercise the suffix-swap with a port preserved.
|
||||
it('matches the domain suffix with its port when one is configured', () => {
|
||||
const localConfig = {
|
||||
domain: 'puter.localhost:4100',
|
||||
static_hosting_domain: 'site.puter.localhost:4100',
|
||||
} as IConfig;
|
||||
const { out } = run(
|
||||
createUserSubdomainRedirect(localConfig),
|
||||
const { next } = run(
|
||||
createUserSubdomainNotFound(localConfig),
|
||||
makeReq({
|
||||
subdomains: ['localhost', 'puter', 'foo'],
|
||||
host: 'foo.puter.localhost:4100',
|
||||
@@ -262,10 +258,7 @@ describe('createUserSubdomainRedirect', () => {
|
||||
protocol: 'http',
|
||||
}),
|
||||
);
|
||||
expect(out.redirectArgs).toEqual([
|
||||
302,
|
||||
'http://foo.site.puter.localhost:4100/x',
|
||||
]);
|
||||
expectNotFound(next);
|
||||
});
|
||||
|
||||
const selfHosted = {
|
||||
@@ -276,9 +269,9 @@ describe('createUserSubdomainRedirect', () => {
|
||||
private_app_hosting_domain_alt: 'dev.puter.localhost',
|
||||
} as IConfig;
|
||||
|
||||
it('still redirects a bare subdomain on the main domain to the hosting domain (self-hosted)', () => {
|
||||
const { out, next } = run(
|
||||
createUserSubdomainRedirect(selfHosted),
|
||||
it('404s a bare subdomain on the main domain (self-hosted)', () => {
|
||||
const { next } = run(
|
||||
createUserSubdomainNotFound(selfHosted),
|
||||
makeReq({
|
||||
subdomains: ['localhost', 'puter', 'foo'],
|
||||
host: 'foo.puter.localhost',
|
||||
@@ -286,24 +279,19 @@ describe('createUserSubdomainRedirect', () => {
|
||||
protocol: 'http',
|
||||
}),
|
||||
);
|
||||
expect(out.redirectArgs).toEqual([
|
||||
302,
|
||||
'http://foo.site.puter.localhost/bar?x=1',
|
||||
]);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expectNotFound(next);
|
||||
});
|
||||
|
||||
it('passes through hosts already on the static hosting domain (no redirect loop)', () => {
|
||||
const { out, next } = run(
|
||||
createUserSubdomainRedirect(selfHosted),
|
||||
it('passes through hosts on the static hosting domain nested under the main domain', () => {
|
||||
const { next } = run(
|
||||
createUserSubdomainNotFound(selfHosted),
|
||||
makeReq({
|
||||
subdomains: ['localhost', 'puter', 'site', 'foo'],
|
||||
host: 'foo.site.puter.localhost',
|
||||
originalUrl: '/',
|
||||
}),
|
||||
);
|
||||
expect(out.redirectArgs).toBeUndefined();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('passes through hosts on the alt / private-app hosting domains too', () => {
|
||||
@@ -312,8 +300,8 @@ describe('createUserSubdomainRedirect', () => {
|
||||
'foo.app.puter.localhost',
|
||||
'foo.dev.puter.localhost',
|
||||
]) {
|
||||
const { out, next } = run(
|
||||
createUserSubdomainRedirect(selfHosted),
|
||||
const { next } = run(
|
||||
createUserSubdomainNotFound(selfHosted),
|
||||
makeReq({
|
||||
subdomains: [
|
||||
'localhost',
|
||||
@@ -324,40 +312,37 @@ describe('createUserSubdomainRedirect', () => {
|
||||
host,
|
||||
}),
|
||||
);
|
||||
expect(out.redirectArgs).toBeUndefined();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
}
|
||||
});
|
||||
|
||||
it('passes through the hosting-domain root itself (exact match, no loop)', () => {
|
||||
const { out, next } = run(
|
||||
createUserSubdomainRedirect(selfHosted),
|
||||
it('passes through the hosting-domain root itself', () => {
|
||||
const { next } = run(
|
||||
createUserSubdomainNotFound(selfHosted),
|
||||
makeReq({
|
||||
subdomains: ['localhost', 'puter', 'site'],
|
||||
host: 'site.puter.localhost',
|
||||
}),
|
||||
);
|
||||
expect(out.redirectArgs).toBeUndefined();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("passes through when the request host has a port the configured domain doesn't", () => {
|
||||
// Edge case worth pinning: the suffix check is exact-`endsWith`,
|
||||
// so a port mismatch silently bypasses the redirect. Documenting
|
||||
// it here so a future refactor doesn't change behavior unawares.
|
||||
// so a port mismatch silently bypasses the check. Documenting it
|
||||
// here so a future refactor doesn't change behavior unawares.
|
||||
const portlessConfig = {
|
||||
domain: 'puter.localhost',
|
||||
static_hosting_domain: 'site.puter.localhost',
|
||||
} as IConfig;
|
||||
const { out, next } = run(
|
||||
createUserSubdomainRedirect(portlessConfig),
|
||||
const { next } = run(
|
||||
createUserSubdomainNotFound(portlessConfig),
|
||||
makeReq({
|
||||
subdomains: ['localhost', 'puter', 'foo'],
|
||||
host: 'foo.puter.localhost:4100',
|
||||
}),
|
||||
);
|
||||
expect(out.redirectArgs).toBeUndefined();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { IConfig } from '../../../types';
|
||||
import { assertNormalized } from '../../../services/fs/resolveNode.js';
|
||||
import { HttpError } from '../HttpError';
|
||||
|
||||
/** Native-app subdomains served via `nativeAppStatic`. */
|
||||
const NATIVE_APP_SUBDOMAINS = [
|
||||
@@ -39,8 +40,7 @@ const NATIVE_APPS_WITH_DIST = new Set(['docs', 'developer']);
|
||||
|
||||
/**
|
||||
* Subdomains that v2 serves itself. Anything NOT in this set that lives on the
|
||||
* root domain is treated as a user-defined site and redirected to the static
|
||||
* hosting domain.
|
||||
* root domain is treated as a user-defined site and rejected with a 404.
|
||||
*
|
||||
* Kept as a plain Set so `has()` is O(1); order doesn't matter.
|
||||
*/
|
||||
@@ -69,22 +69,23 @@ export const createWwwRedirect = (config: IConfig): RequestHandler => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Redirects user-defined subdomains on the main domain to the static hosting
|
||||
* domain. `foo.puter.com/bar?x=1` → `302 foo.puter.site/bar?x=1`.
|
||||
* Rejects user-defined subdomains on the main domain with a 404
|
||||
* (`foo.puter.com/...`). User sites are served only from the static hosting
|
||||
* domain; the main domain must not act as an alias for them.
|
||||
*
|
||||
* Passes through when:
|
||||
*
|
||||
* - No active subdomain (root)
|
||||
* - Active subdomain is reserved (api, js, native apps, …)
|
||||
* - Host is on one of the hosting domains (they may nest under `config.domain`)
|
||||
* - Host doesn't end in `config.domain` (custom domains, other hosts)
|
||||
* - `static_hosting_domain` isn't configured
|
||||
* - `static_hosting_domain` isn't configured (no separate hosting domain)
|
||||
*/
|
||||
export const createUserSubdomainRedirect = (
|
||||
export const createUserSubdomainNotFound = (
|
||||
config: IConfig,
|
||||
): RequestHandler => {
|
||||
const domain = (config.domain ?? '').toLowerCase();
|
||||
const target = (config.static_hosting_domain ?? '').toLowerCase();
|
||||
if (!domain || !target) {
|
||||
if (!domain || !config.static_hosting_domain) {
|
||||
return (_req, _res, next) => next();
|
||||
}
|
||||
|
||||
@@ -113,10 +114,7 @@ export const createUserSubdomainRedirect = (
|
||||
}
|
||||
if (!host.endsWith(domain)) return next();
|
||||
|
||||
// host ends in domain — swap the domain suffix for the hosting one,
|
||||
// preserving the subdomain prefix and any port.
|
||||
const newHost = host.slice(0, host.length - domain.length) + target;
|
||||
res.redirect(302, `${req.protocol}://${newHost}${req.originalUrl}`);
|
||||
next(new HttpError(404, 'Not Found', { legacyCode: 'not_found' }));
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -501,7 +501,8 @@ function resolveBackend(name) {
|
||||
* Strategies: 'fingerprint' — network hash (IP + headers), refined by the
|
||||
* client's device fingerprint when one was supplied (default). Good for
|
||||
* unauthenticated endpoints where the same IP may serve many users (offices,
|
||||
* VPNs). 'ip' — bare IP. Simpler but coarser. 'user' — actor UUID. Use for
|
||||
* VPNs). 'ip' — bare IP. Simpler but coarser. 'user' — the authenticated actor
|
||||
* (user, plus the app and worker it acts through; see `actorKey`). Use for
|
||||
* authenticated endpoints where you want per-account limits regardless of IP.
|
||||
* function — custom `(req) => string`.
|
||||
*/
|
||||
@@ -520,7 +521,7 @@ function resolveKey(req, scope, strategy) {
|
||||
// on requireAuth routes, but be safe)
|
||||
return prefix + fingerprint(req);
|
||||
}
|
||||
return prefix + id;
|
||||
return prefix + actorKey(req.actor, id);
|
||||
}
|
||||
case 'ip':
|
||||
return prefix + ip(req);
|
||||
@@ -530,6 +531,25 @@ function resolveKey(req, scope, strategy) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket identity for an authenticated actor: `<user>[:<app>][:<worker>]`.
|
||||
*
|
||||
* The app segment is the app the actor acts as (`effectiveApp`, so an access
|
||||
* token minted by an app lands in that app's bucket). The worker segment is the
|
||||
* worker's session uid, unique per (user, app, worker name). Without these, a
|
||||
* busy app or worker drains the limit shared by everything else the same user
|
||||
* runs.
|
||||
*/
|
||||
function actorKey(actor, userId) {
|
||||
const parts = [userId];
|
||||
const app = actor.effectiveApp ?? actor.app;
|
||||
if (app?.uid) parts.push(app.uid);
|
||||
if (actor.session?.kind === 'worker' && actor.session.uid) {
|
||||
parts.push(actor.session.uid);
|
||||
}
|
||||
return parts.join(':');
|
||||
}
|
||||
|
||||
function ip(req) {
|
||||
// `req.ip` honors the app-level `trust proxy` setting — it returns the
|
||||
// leftmost untrusted XFF address when behind the configured proxy chain
|
||||
@@ -625,10 +645,18 @@ export function rateLimitGate(opts) {
|
||||
|
||||
// -- Driver-call helper ----------------------------------------------
|
||||
|
||||
function driverCaller(req) {
|
||||
const actor = req.actor;
|
||||
return actor?.user?.uuid
|
||||
? actorKey(actor, actor.user.uuid)
|
||||
: fingerprint(req);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check rate limit for a driver call. Called from DriverController's /call
|
||||
* handler. Keyed by user + interface:method so different drivers and different
|
||||
* methods don't crowd each other.
|
||||
* handler. Keyed by actor (user, app, worker — see `actorKey`) +
|
||||
* interface:method so different drivers, methods, apps and workers don't crowd
|
||||
* each other.
|
||||
*
|
||||
* `opts` is the resolved per-method spec from the driver's decorator (or
|
||||
* imperative `rateLimit` field) — see `resolveDriverRateLimit` in
|
||||
@@ -641,8 +669,7 @@ export function rateLimitGate(opts) {
|
||||
*/
|
||||
export async function checkDriverRateLimit(req, ifaceName, method, opts = {}) {
|
||||
const { window: windowMs = 60_000, backend } = opts;
|
||||
const uid = req.actor?.user?.uuid || fingerprint(req);
|
||||
const key = `driver:${ifaceName}:${method}:${uid}`;
|
||||
const key = `driver:${ifaceName}:${method}:${driverCaller(req)}`;
|
||||
const backendPair = resolveBackend(backend);
|
||||
try {
|
||||
// Drivers can pin a per-subscription limit via `bySubscription`
|
||||
@@ -876,8 +903,7 @@ export async function acquireDriverConcurrent(req, ifaceName, method, opts) {
|
||||
return { ok: true, release: () => {} };
|
||||
}
|
||||
const { backend } = opts;
|
||||
const uid = req.actor?.user?.uuid || fingerprint(req);
|
||||
const key = `driver:${ifaceName}:${method}:${uid}`;
|
||||
const key = `driver:${ifaceName}:${method}:${driverCaller(req)}`;
|
||||
const backendPair = resolveBackend(backend);
|
||||
try {
|
||||
const limit = await resolveSubscriptionLimit(req, opts);
|
||||
|
||||
@@ -90,6 +90,38 @@ describe('rateLimitGate — memory backend (default)', () => {
|
||||
expect(rejected.legacyCode).toBe('too_many_requests');
|
||||
});
|
||||
|
||||
it("'user' strategy separates one user's apps and workers", async () => {
|
||||
const opts = {
|
||||
limit: 1,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
scope: 'mem-user-app',
|
||||
};
|
||||
const user = { id: 7 };
|
||||
const reqs = [
|
||||
makeReq({ actor: { user, effectiveApp: null } }),
|
||||
makeReq({
|
||||
actor: {
|
||||
user,
|
||||
app: { uid: 'app-1' },
|
||||
effectiveApp: { uid: 'app-1' },
|
||||
},
|
||||
}),
|
||||
makeReq({
|
||||
actor: {
|
||||
user,
|
||||
app: { uid: 'app-1' },
|
||||
effectiveApp: { uid: 'app-1' },
|
||||
session: { uid: 'w-1', kind: 'worker' },
|
||||
},
|
||||
}),
|
||||
];
|
||||
for (const req of reqs)
|
||||
expect(await runGate(opts, req)).toBeUndefined();
|
||||
for (const req of reqs)
|
||||
expect(isHttpError(await runGate(opts, req))).toBe(true);
|
||||
});
|
||||
|
||||
it("'user' strategy buckets by actor.user.id (different users don't crowd)", async () => {
|
||||
const opts = {
|
||||
limit: 1,
|
||||
@@ -590,6 +622,89 @@ describe('checkDriverRateLimit', () => {
|
||||
configureRateLimit();
|
||||
});
|
||||
|
||||
it('buckets the same user separately per app and per worker', async () => {
|
||||
const user = { uuid: 'user-apps' };
|
||||
const plain = { actor: { user, effectiveApp: null } };
|
||||
const appA = {
|
||||
actor: {
|
||||
user,
|
||||
app: { uid: 'app-a' },
|
||||
effectiveApp: { uid: 'app-a' },
|
||||
},
|
||||
};
|
||||
const appB = {
|
||||
actor: {
|
||||
user,
|
||||
app: { uid: 'app-b' },
|
||||
effectiveApp: { uid: 'app-b' },
|
||||
},
|
||||
};
|
||||
const workerA = {
|
||||
actor: {
|
||||
user,
|
||||
app: { uid: 'app-a' },
|
||||
effectiveApp: { uid: 'app-a' },
|
||||
session: { uid: 'sess-w1', kind: 'worker' },
|
||||
},
|
||||
};
|
||||
const workerA2 = {
|
||||
actor: {
|
||||
user,
|
||||
app: { uid: 'app-a' },
|
||||
effectiveApp: { uid: 'app-a' },
|
||||
session: { uid: 'sess-w2', kind: 'worker' },
|
||||
},
|
||||
};
|
||||
for (const req of [plain, appA, appB, workerA, workerA2]) {
|
||||
expect(await checkDriverRateLimit(req, 'kv', 'get', spec(1))).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
for (const req of [plain, appA, appB, workerA, workerA2]) {
|
||||
expect(await checkDriverRateLimit(req, 'kv', 'get', spec(1))).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('lands an app-issued access token in the issuing app bucket', async () => {
|
||||
const user = { uuid: 'user-token' };
|
||||
const app = {
|
||||
actor: {
|
||||
user,
|
||||
app: { uid: 'app-x' },
|
||||
effectiveApp: { uid: 'app-x' },
|
||||
},
|
||||
};
|
||||
const token = { actor: { user, effectiveApp: { uid: 'app-x' } } };
|
||||
expect(await checkDriverRateLimit(app, 'kv', 'get', spec(1))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(await checkDriverRateLimit(token, 'kv', 'get', spec(1))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("a non-worker session doesn't add a segment", async () => {
|
||||
const user = { uuid: 'user-web' };
|
||||
const a = {
|
||||
actor: {
|
||||
user,
|
||||
effectiveApp: null,
|
||||
session: { uid: 's1', kind: 'web' },
|
||||
},
|
||||
};
|
||||
const b = {
|
||||
actor: {
|
||||
user,
|
||||
effectiveApp: null,
|
||||
session: { uid: 's2', kind: 'web' },
|
||||
},
|
||||
};
|
||||
expect(await checkDriverRateLimit(a, 'kv', 'get', spec(1))).toBe(true);
|
||||
expect(await checkDriverRateLimit(b, 'kv', 'get', spec(1))).toBe(false);
|
||||
});
|
||||
|
||||
const spec = (limit, window = 60_000, backend) => ({
|
||||
limit,
|
||||
window,
|
||||
@@ -1091,6 +1206,36 @@ describe('acquireDriverConcurrent', () => {
|
||||
configureRateLimit();
|
||||
});
|
||||
|
||||
it('slots are per app and per worker for the same user', async () => {
|
||||
const user = { uuid: 'conc-user' };
|
||||
const app = {
|
||||
actor: {
|
||||
user,
|
||||
app: { uid: 'app-c' },
|
||||
effectiveApp: { uid: 'app-c' },
|
||||
},
|
||||
};
|
||||
const worker = {
|
||||
actor: {
|
||||
user,
|
||||
app: { uid: 'app-c' },
|
||||
effectiveApp: { uid: 'app-c' },
|
||||
session: { uid: 'w-c', kind: 'worker' },
|
||||
},
|
||||
};
|
||||
const a = await acquireDriverConcurrent(app, 'kv', 'get', { limit: 1 });
|
||||
expect(a.ok).toBe(true);
|
||||
expect(
|
||||
(await acquireDriverConcurrent(app, 'kv', 'get', { limit: 1 })).ok,
|
||||
).toBe(false);
|
||||
const w = await acquireDriverConcurrent(worker, 'kv', 'get', {
|
||||
limit: 1,
|
||||
});
|
||||
expect(w.ok).toBe(true);
|
||||
await a.release();
|
||||
await w.release();
|
||||
});
|
||||
|
||||
it('returns an always-ok handle with a noop release when no spec is declared', async () => {
|
||||
// Drivers that declare nothing stay unbounded — same as before
|
||||
// this feature was introduced.
|
||||
|
||||
@@ -315,7 +315,7 @@ describe('PuterServer host header validation', () => {
|
||||
* Express reads subdomains relative to a fixed label count, so a root domain
|
||||
* deeper than two labels is the case that breaks: `puter` reads as an active
|
||||
* subdomain of the root origin itself, which bounces every root request into
|
||||
* the user-site redirect.
|
||||
* the user-subdomain 404.
|
||||
*/
|
||||
describe('PuterServer subdomain routing on a multi-label root domain', () => {
|
||||
let server: PuterServer;
|
||||
@@ -342,24 +342,22 @@ describe('PuterServer subdomain routing on a multi-label root domain', () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
// Host headers here carry no port: the redirect under test compares the
|
||||
// Host headers here carry no port: the gate under test compares the
|
||||
// host against `domain`, which is how it arrives from a proxy in practice.
|
||||
it('serves the root origin instead of redirecting it to the hosting domain', async () => {
|
||||
it('serves the root origin instead of treating it as a user subdomain', async () => {
|
||||
const res = await rawRequest(port, '/', {
|
||||
host: 'puter.example.localhost',
|
||||
});
|
||||
expect(res.status).not.toBe(302);
|
||||
expect(res.status).not.toBe(404);
|
||||
expect(res.headers.location).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still redirects a user subdomain of that domain to the hosting domain', async () => {
|
||||
it('still 404s a user subdomain of that domain', async () => {
|
||||
const res = await rawRequest(port, '/some/path', {
|
||||
host: 'alice.puter.example.localhost',
|
||||
});
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe(
|
||||
'http://alice.site.puter.example.localhost/some/path',
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.headers.location).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still recognizes reserved subdomains of that domain', async () => {
|
||||
|
||||
@@ -71,7 +71,7 @@ import {
|
||||
} from './core/http/middleware/rateLimit';
|
||||
import {
|
||||
createWwwRedirect,
|
||||
createUserSubdomainRedirect,
|
||||
createUserSubdomainNotFound,
|
||||
createNativeAppStatic,
|
||||
} from './core/http/middleware/hostRedirects';
|
||||
import { createEgressMeteringMiddleware } from './core/http/middleware/egressMetering';
|
||||
@@ -464,12 +464,12 @@ export class PuterServer {
|
||||
// -- Host header validation ----------------------------------
|
||||
this.#installHostValidation();
|
||||
|
||||
// -- Host redirects (www → root, user subdomain → static hosting)
|
||||
// -- Host handling (www → root, user subdomain on main domain → 404)
|
||||
// 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.
|
||||
// and before CORS/body-parsing so we short-circuit without burning
|
||||
// work.
|
||||
this.#app.use(createWwwRedirect(this.#config));
|
||||
this.#app.use(createUserSubdomainRedirect(this.#config));
|
||||
this.#app.use(createUserSubdomainNotFound(this.#config));
|
||||
|
||||
// -- Native app static serving (editor.*, docs.*, …) ---------
|
||||
// No-op when `native_apps_root` is unset.
|
||||
|
||||
@@ -15,7 +15,7 @@ Three separate mechanisms decide whether a call succeeds. They are independent,
|
||||
|
||||
A credit balance does not buy rate-limit headroom, and an empty balance does not stop metadata reads that cost nothing. Design for all three.
|
||||
|
||||
Because of the [User-Pays Model](/user-pays-model), every limit below applies **per user**, not per app: your app's traffic is bounded by each of your users' own accounts, so one heavy user can never exhaust your app for everyone else.
|
||||
Because of the [User-Pays Model](/user-pays-model), every limit below applies **per user, per app**: your app's traffic is bounded by each of your users' own accounts, so one heavy user can never exhaust your app for everyone else. Each app a user runs gets its own bucket, and each worker gets its own on top of that, so a busy worker never rate-limits the same user's other apps.
|
||||
|
||||
## Usage credit
|
||||
|
||||
@@ -37,7 +37,7 @@ AI requests you have in flight count against the balance while they run, at the
|
||||
|
||||
## Rate limits
|
||||
|
||||
Every limit is a rolling window, keyed per user. Where three numbers are shown they are **paid / free / anonymous** — "paid" is any subscription tier.
|
||||
Every limit is a rolling window, keyed per user and app (and per worker, for calls made from a worker). Where three numbers are shown they are **paid / free / anonymous** — "paid" is any subscription tier.
|
||||
|
||||
### AI
|
||||
|
||||
|
||||
Reference in New Issue
Block a user