fix: don't redirect puter.com (#3723)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

This commit is contained in:
Daniel Salazar
2026-09-02 11:57:30 -07:00
committed by GitHub
parent fd7b517448
commit fb8e2976dc
7 changed files with 265 additions and 113 deletions
@@ -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' }));
};
};
+34 -8
View File
@@ -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.
+7 -9
View File
@@ -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 () => {
+5 -5
View File
@@ -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.
+2 -2
View File
@@ -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