diff --git a/src/backend/core/http/middleware/hostRedirects.test.ts b/src/backend/core/http/middleware/hostRedirects.test.ts index 440269af4..54bfc06a1 100644 --- a/src/backend/core/http/middleware/hostRedirects.test.ts +++ b/src/backend/core/http/middleware/hostRedirects.test.ts @@ -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) => { + 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(); }); }); diff --git a/src/backend/core/http/middleware/hostRedirects.ts b/src/backend/core/http/middleware/hostRedirects.ts index cb46f6dbc..fd6b8490e 100644 --- a/src/backend/core/http/middleware/hostRedirects.ts +++ b/src/backend/core/http/middleware/hostRedirects.ts @@ -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' })); }; }; diff --git a/src/backend/core/http/middleware/rateLimit.js b/src/backend/core/http/middleware/rateLimit.js index 7300aa436..94f5426f9 100644 --- a/src/backend/core/http/middleware/rateLimit.js +++ b/src/backend/core/http/middleware/rateLimit.js @@ -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: `[:][:]`. + * + * 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); diff --git a/src/backend/core/http/middleware/rateLimit.test.js b/src/backend/core/http/middleware/rateLimit.test.js index 81d95dfc1..eba19f91a 100644 --- a/src/backend/core/http/middleware/rateLimit.test.js +++ b/src/backend/core/http/middleware/rateLimit.test.js @@ -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. diff --git a/src/backend/server.test.ts b/src/backend/server.test.ts index f7db63adf..8418ea89a 100644 --- a/src/backend/server.test.ts +++ b/src/backend/server.test.ts @@ -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 () => { diff --git a/src/backend/server.ts b/src/backend/server.ts index c1fed9115..695d21562 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -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. diff --git a/src/docs/src/rate-limits-and-quotas.md b/src/docs/src/rate-limits-and-quotas.md index 1d199e9c0..462e84eff 100644 --- a/src/docs/src/rate-limits-and-quotas.md +++ b/src/docs/src/rate-limits-and-quotas.md @@ -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