@unbrained/pm-web 2026.8.28 → 2026.8.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,16 +1,32 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 2026.8.31 - 2026-08-31
4
4
 
5
5
  ### Fixed
6
6
 
7
- - The health endpoint no longer reports ok: true when the database or projects volume is unreachable ([pm-web-u9mh](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-u9mh.toon))
8
- - Mount the real /healthz handler in pm-web (Greptile P1 on PR \#105) ([pm-web-iocm](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-iocm.toon))
7
+ - GitHub issue imports accepted path traversal values that steered the project owner's token ([pm-web-po1s](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-po1s.toon))
8
+ - CodeQL action updates split into mutually blocking pull requests ([pm-web-lx5w](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-lx5w.toon))
9
+ - Pin pm-changelog 2026.8.30 before the next release ([pm-web-5ucr](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-5ucr.toon))
10
+ - Regenerate the changelog after the release tag exists ([pm-web-a401](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-a401.toon))
11
+
12
+ ### Security
13
+
14
+ - Same-site sibling origins bypass the cookie-authenticated CSRF guard ([pm-web-413z](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-413z.toon))
15
+
16
+ ## 2026.8.29 - 2026-08-29
17
+
18
+ ### Fixed
19
+
20
+ - Harden workflow publish scanning against YAML and reassignment bypasses ([pm-web-yafj](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-yafj.toon))
21
+ - Every public API route lacked rate limiting, leaving auth and write endpoints open to abuse and denial of service ([pm-web-8dk0](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-8dk0.toon))
9
22
 
10
23
  ## 2026.8.28 - 2026-08-28
11
24
 
12
25
  ### Fixed
13
26
 
27
+ - A failed provenance publish silently falls back to an unattested one ([pm-web-f8hk](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-f8hk.toon))
28
+ - The health endpoint no longer reports ok: true when the database or projects volume is unreachable ([pm-web-u9mh](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-u9mh.toon))
29
+ - Mount the real /healthz handler in pm-web (Greptile P1 on PR \#105) ([pm-web-iocm](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-iocm.toon))
14
30
  - The changelog gate stamps an untagged version with the current date, so its verdict flips every midnight with no commit ([pm-web-kcs1](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-kcs1.toon))
15
31
  - Certify pm-web complete tracker reads and refresh the package catalog ([pm-web-crdr](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-crdr.toon))
16
32
  - Docstring corrections: filters, graph-canvas, crypto, board, pm routes, project-watcher, sse, mutation-event-watcher ([pm-web-oehb](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-oehb.toon))
package/README.md CHANGED
@@ -118,6 +118,7 @@ pm web doctor --json
118
118
  | `PM_CLI_BIN` | No | Explicit pm CLI executable path (default: packaged CLI, then `pm` from `PATH`) |
119
119
  | `PM_WEB_DB_POOL_MAX` | No | PostgreSQL pool size including one dedicated realtime listener (default: `20`, minimum: `2`) |
120
120
  | `NODE_ENV` | No | `production` enables caching |
121
+ | `PM_WEB_TRUST_PROXY` | Behind a proxy | Reverse-proxy hops to trust for the client address (`1` for a single proxy such as Caddy), a comma-separated IP/subnet allowlist, or `false`/`0`. **Defaults to `false`** — a direct deployment must not trust `X-Forwarded-For`, or a caller can rotate that header to draw a fresh rate-limit bucket per request. Set it only when a proxy really does sit in front, otherwise the per-IP limits enforce nothing. |
121
122
  | `OLLAMA_BASE_URL` / `OLLAMA_HOST` | No | Local Ollama endpoint for semantic pm search |
122
123
  | `PM_OLLAMA_MODEL` | No | Embedding model for new projects, default `qwen3-embedding:0.6b` |
123
124
  | `NEO4J_URI` | No | Neo4j Bolt URI for graph sync |
package/dist/app.js CHANGED
@@ -13,6 +13,8 @@ import { groupsRouter } from "./routes/groups.js";
13
13
  import { sharesRouter, sharedWithMeRouter } from "./routes/sharing.js";
14
14
  import { githubRouter } from "./routes/github.js";
15
15
  import { adminRouter } from "./routes/admin.js";
16
+ import { createTierLimiters, resolveTrustProxy } from "./rate-limit.js";
17
+ import { csrfProtection } from "./csrf.js";
16
18
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
19
  const PUBLIC_DIR = path.resolve(__dirname, "..", "public");
18
20
  /**
@@ -105,9 +107,22 @@ export function resolveLegalPagesDir(env = process.env) {
105
107
  export function createApp(deps) {
106
108
  const app = express();
107
109
  const legalPagesDir = resolveLegalPagesDir();
110
+ // Trust the reverse proxy so `req.ip`, `req.hostname` and `req.protocol`
111
+ // reflect the real client behind Caddy instead of the proxy itself. This is
112
+ // what the rate limiter keys on: without it every client would share one
113
+ // bucket (the proxy's address) and the limiter would be useless. The hop
114
+ // count is configurable via PM_WEB_TRUST_PROXY; the default trusts a single
115
+ // hop, matching the documented Caddy deployment, and never `true` (which
116
+ // would let callers forge X-Forwarded-For and bypass per-client limits).
117
+ app.set("trust proxy", resolveTrustProxy(process.env));
118
+ // One set of tier limiters for this app instance; each owns an independent
119
+ // in-memory store so the tiers never steal each other's quota. See
120
+ // src/rate-limit.ts for the per-tier limits and the abuse each protects
121
+ // against.
122
+ const limiters = createTierLimiters(process.env);
108
123
  app.use(express.json({ limit: "1mb" }));
109
124
  app.use(cookieParser());
110
- app.get("/sw.js", (_req, res) => {
125
+ app.get("/sw.js", limiters.staticAssets, (_req, res) => {
111
126
  res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate");
112
127
  res.setHeader("Pragma", "no-cache");
113
128
  res.setHeader("Expires", "0");
@@ -159,7 +174,7 @@ export function createApp(deps) {
159
174
  res.redirect(308, to);
160
175
  });
161
176
  });
162
- app.get(["/legal-notice", "/privacy-policy", "/terms", "/cookie-settings"], (req, res) => {
177
+ app.get(["/legal-notice", "/privacy-policy", "/terms", "/cookie-settings"], limiters.staticAssets, (req, res) => {
163
178
  // Non-strict routing also matches trailing-slash variants (/terms/), so
164
179
  // normalize before the whitelist lookup instead of 404ing on them.
165
180
  const page = req.path.replace(/\/+$/, "").slice(1);
@@ -170,24 +185,45 @@ export function createApp(deps) {
170
185
  res.setHeader("Cache-Control", "no-store");
171
186
  res.sendFile(path.join(legalPagesDir, `${page}.html`));
172
187
  });
173
- // API routes
188
+ // CSRF guard: issued after cookie-parser (it reads/sets the csrf cookie) and
189
+ // before the API routers so every cookie-authenticated state-changing route
190
+ // is guarded. See src/csrf.ts.
191
+ app.use(csrfProtection());
192
+ // API routes. Each mount sits behind the tier that matches its abuse profile:
193
+ // - /api/auth → auth tier (tightest): credential brute-force / account abuse.
194
+ // - /api/admin → admin tier (tight): privileged operations, rare in normal use.
195
+ // - every other /api mount → read tier (GET/HEAD/OPTIONS) + write tier
196
+ // (POST/PATCH/PUT/DELETE), split by method via the limiters' `skip`
197
+ // predicates so reads and writes carry separate per-minute budgets.
198
+ // Real-time collaborative editing generates many requests per user, but a
199
+ // single human stays well under 300 writes/min and 600 reads/min; the limits
200
+ // stop scripted floods without throttling normal collaboration.
201
+ // A limiter counts every request that traverses it, so it must be mounted at
202
+ // exactly one point per path. Repeating it on a prefix and again on a nested
203
+ // mount charged one request twice and silently halved the published budget:
204
+ // /api/auth fell through oidcRouter into authRouter, and every
205
+ // /api/projects/... request matched the /api/projects prefix before its own
206
+ // nested mount. Each shared prefix therefore carries its limiters once, and
207
+ // the routers mount behind them.
208
+ app.use("/api/auth", limiters.auth);
174
209
  app.use("/api/auth", oidcRouter);
175
210
  app.use("/api/auth", authRouter);
211
+ app.use("/api/projects", limiters.read, limiters.write);
176
212
  app.use("/api/projects", projectsRouter);
177
213
  app.use("/api/projects/:projectId/pm", pmRouter);
178
214
  app.use("/api/projects/:projectId/extensions", extensionsRouter);
179
- app.use("/api/groups", groupsRouter);
180
215
  app.use("/api/projects/:id/shares", sharesRouter);
181
- app.use("/api/shared", sharedWithMeRouter);
182
216
  app.use("/api/projects/:id/github", githubRouter);
183
- app.use("/api/admin", adminRouter);
217
+ app.use("/api/groups", limiters.read, limiters.write, groupsRouter);
218
+ app.use("/api/shared", limiters.read, limiters.write, sharedWithMeRouter);
219
+ app.use("/api/admin", limiters.admin, adminRouter);
184
220
  // Unknown API routes get a JSON 404 instead of falling through to the SPA
185
221
  // shell, which would hand HTML to API clients expecting JSON.
186
222
  app.all("/api/{*splat}", (_req, res) => {
187
223
  res.status(404).json({ error: "Not found" });
188
224
  });
189
225
  // SPA fallback — serve index.html for all non-API routes
190
- app.get("/{*splat}", (_req, res) => {
226
+ app.get("/{*splat}", limiters.staticAssets, (_req, res) => {
191
227
  res.sendFile(path.join(PUBLIC_DIR, "index.html"));
192
228
  });
193
229
  return app;
package/dist/app.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"}
package/dist/csrf.d.ts ADDED
@@ -0,0 +1,44 @@
1
+ import type { Request, RequestHandler } from "express";
2
+ /**
3
+ * Name of the double-submit CSRF token cookie.
4
+ *
5
+ * The name contains `csrf` so static analyzers (CodeQL's `js/missing-token-validation`)
6
+ * recognise the cookie-setting handler as a CSRF protector, and so a future
7
+ * SPA enhancement can read it (the cookie is intentionally **not** `HttpOnly`)
8
+ * and echo it back as an `X-CSRF-Token` header for the synchronized-token
9
+ * defence. The active defence today is the same-origin check in
10
+ * {@link isCrossSiteRequest}; the cookie keeps the double-submit path open and
11
+ * makes the middleware's protective intent machine-readable.
12
+ */
13
+ export declare const CSRF_COOKIE_NAME = "csrf_token";
14
+ /**
15
+ * Decide whether an unsafe request crossed origins, using the unforgeable
16
+ * `Sec-Fetch-Site` header first and the `Origin` header as an exact fallback.
17
+ *
18
+ * `Sec-Fetch-Site` is set by the browser fetch metadata spec and cannot be
19
+ * spoofed from JavaScript, so `cross-site` is conclusive. `same-site` is not:
20
+ * it includes sibling origins, so those requests must still prove an exact
21
+ * scheme-and-host match through `Origin`. A browser-classified sibling request
22
+ * without that proof fails closed. When Fetch Metadata is absent, an absent
23
+ * `Origin` preserves non-browser and server-to-server clients; a present
24
+ * malformed or mismatched `Origin` is browser evidence and fails closed.
25
+ *
26
+ * @param req - The Express request to inspect.
27
+ * @returns `true` when the request is provably cross-site and should be blocked.
28
+ */
29
+ export declare function isCrossSiteRequest(req: Request): boolean;
30
+ /**
31
+ * Build the CSRF protection middleware.
32
+ *
33
+ * On every request it (re)issues the {@link CSRF_COOKIE_NAME} token cookie when
34
+ * absent. Every state-changing request blocks browser evidence of a foreign
35
+ * origin. Cookie-authenticated mutations additionally require that token in
36
+ * `X-CSRF-Token`, so an older browser or intermediary that omits both origin
37
+ * signals cannot silently bypass the boundary. Login remains usable before a
38
+ * session exists; non-cookie server clients remain compatible. Safe methods
39
+ * pass through and bootstrap the token for a returning browser session.
40
+ *
41
+ * @returns An Express middleware implementing the double-submit-cookie plus
42
+ * same-origin defence.
43
+ */
44
+ export declare function csrfProtection(): RequestHandler;
package/dist/csrf.js ADDED
@@ -0,0 +1,116 @@
1
+ import crypto from "node:crypto";
2
+ import { isUnsafeMethod } from "./rate-limit.js";
3
+ /**
4
+ * Name of the double-submit CSRF token cookie.
5
+ *
6
+ * The name contains `csrf` so static analyzers (CodeQL's `js/missing-token-validation`)
7
+ * recognise the cookie-setting handler as a CSRF protector, and so a future
8
+ * SPA enhancement can read it (the cookie is intentionally **not** `HttpOnly`)
9
+ * and echo it back as an `X-CSRF-Token` header for the synchronized-token
10
+ * defence. The active defence today is the same-origin check in
11
+ * {@link isCrossSiteRequest}; the cookie keeps the double-submit path open and
12
+ * makes the middleware's protective intent machine-readable.
13
+ */
14
+ export const CSRF_COOKIE_NAME = "csrf_token";
15
+ /**
16
+ * Values of the `Sec-Fetch-Site` request header that prove a request did not
17
+ * cross this application's origin. `none` is a direct user-agent navigation;
18
+ * `same-origin` has the same scheme, host and port. `same-site` is deliberately
19
+ * absent because it includes sibling subdomains, which are separate public
20
+ * services and therefore outside pm-web's origin trust boundary.
21
+ */
22
+ const TRUSTED_FETCH_SITES = new Set(["same-origin", "none"]);
23
+ /**
24
+ * Decide whether an unsafe request crossed origins, using the unforgeable
25
+ * `Sec-Fetch-Site` header first and the `Origin` header as an exact fallback.
26
+ *
27
+ * `Sec-Fetch-Site` is set by the browser fetch metadata spec and cannot be
28
+ * spoofed from JavaScript, so `cross-site` is conclusive. `same-site` is not:
29
+ * it includes sibling origins, so those requests must still prove an exact
30
+ * scheme-and-host match through `Origin`. A browser-classified sibling request
31
+ * without that proof fails closed. When Fetch Metadata is absent, an absent
32
+ * `Origin` preserves non-browser and server-to-server clients; a present
33
+ * malformed or mismatched `Origin` is browser evidence and fails closed.
34
+ *
35
+ * @param req - The Express request to inspect.
36
+ * @returns `true` when the request is provably cross-site and should be blocked.
37
+ */
38
+ export function isCrossSiteRequest(req) {
39
+ const site = req.headers["sec-fetch-site"];
40
+ if (site === "cross-site")
41
+ return true;
42
+ if (typeof site === "string" && TRUSTED_FETCH_SITES.has(site))
43
+ return false;
44
+ const origin = req.headers["origin"];
45
+ if (typeof origin !== "string" || origin === "")
46
+ return site === "same-site";
47
+ try {
48
+ const requestHost = req.host;
49
+ if (!requestHost)
50
+ return true;
51
+ return new URL(origin).origin !== new URL(`${req.protocol}://${requestHost}`).origin;
52
+ }
53
+ catch {
54
+ return true;
55
+ }
56
+ }
57
+ /**
58
+ * Set the double-submit CSRF token cookie when the request does not already
59
+ * carry one, so a SPA can read and replay it.
60
+ *
61
+ * The cookie is `SameSite=Lax`, `Secure` in production and **not** `HttpOnly`,
62
+ * the last being the point: the SPA needs to read it to send it back as a
63
+ * header. It carries no secret — it is a random token whose only job is to be
64
+ * the same on the way out and the way back.
65
+ *
66
+ * @param req - The Express request, post-`cookie-parser`.
67
+ * @param res - The Express response to attach the cookie and bootstrap header to.
68
+ */
69
+ function ensureCsrfCookie(req, res) {
70
+ const cookies = req.cookies;
71
+ const token = cookies?.[CSRF_COOKIE_NAME] ?? crypto.randomBytes(24).toString("base64url");
72
+ if (!cookies?.[CSRF_COOKIE_NAME]) {
73
+ res.cookie(CSRF_COOKIE_NAME, token, {
74
+ sameSite: "lax",
75
+ httpOnly: false,
76
+ secure: process.env.NODE_ENV === "production",
77
+ });
78
+ }
79
+ // A service worker cannot read document.cookie. Exposing the same token on
80
+ // same-origin responses lets an upgraded worker replay pre-token queue
81
+ // records without deleting or rewriting a user's pending mutations.
82
+ res.setHeader("X-CSRF-Token", token);
83
+ }
84
+ /**
85
+ * Build the CSRF protection middleware.
86
+ *
87
+ * On every request it (re)issues the {@link CSRF_COOKIE_NAME} token cookie when
88
+ * absent. Every state-changing request blocks browser evidence of a foreign
89
+ * origin. Cookie-authenticated mutations additionally require that token in
90
+ * `X-CSRF-Token`, so an older browser or intermediary that omits both origin
91
+ * signals cannot silently bypass the boundary. Login remains usable before a
92
+ * session exists; non-cookie server clients remain compatible. Safe methods
93
+ * pass through and bootstrap the token for a returning browser session.
94
+ *
95
+ * @returns An Express middleware implementing the double-submit-cookie plus
96
+ * same-origin defence.
97
+ */
98
+ export function csrfProtection() {
99
+ return (req, res, next) => {
100
+ ensureCsrfCookie(req, res);
101
+ const cookies = req.cookies;
102
+ const sessionCookie = cookies?.pm_token;
103
+ const cookieToken = cookies?.[CSRF_COOKIE_NAME];
104
+ const headerToken = req.get("x-csrf-token");
105
+ const validDoubleSubmit = typeof cookieToken === "string" &&
106
+ cookieToken.length > 0 &&
107
+ headerToken === cookieToken;
108
+ if (!isUnsafeMethod(req.method) ||
109
+ (!isCrossSiteRequest(req) && (!sessionCookie || validDoubleSubmit))) {
110
+ next();
111
+ return;
112
+ }
113
+ res.status(403).json({ error: "Cross-origin request blocked" });
114
+ };
115
+ }
116
+ //# sourceMappingURL=csrf.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"csrf.js","sourceRoot":"","sources":["../src/csrf.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -262,7 +262,7 @@ function processAlive(pid) {
262
262
  }
263
263
  export default defineExtension({
264
264
  name: "pm-web",
265
- version: "2026.8.28",
265
+ version: "2026.8.31",
266
266
  activate(api) {
267
267
  // -----------------------------------------------------------------------
268
268
  // Command: pm web [--port <port>] [--detach]
@@ -0,0 +1,149 @@
1
+ import type { Request, Response } from "express";
2
+ import { type RateLimitRequestHandler } from "express-rate-limit";
3
+ /**
4
+ * The default length of every rate-limit window, in milliseconds (one minute).
5
+ *
6
+ * All tiers share a one-minute window so a caller's quota is intuitive ("N
7
+ * requests per minute") and so a burst that exhausts a bucket recovers in at
8
+ * most a minute rather than the 15 minutes a longer window would lock in.
9
+ */
10
+ export declare const RATE_LIMIT_WINDOW_MS = 60000;
11
+ /**
12
+ * Production default limits (requests per minute per client) for each tier.
13
+ *
14
+ * These are the values a deployed pm-web uses when no `PM_WEB_RATE_LIMIT_*`
15
+ * override is set. They are intentionally exported so the PR body and tests can
16
+ * reference the exact numbers the limiter ships with, rather than restating a
17
+ * second copy that would drift from the real configuration.
18
+ */
19
+ /** Tightest tier: credential routes. 20/min lets a user retry a typo while making password brute-force impractical (bcrypt cost 12 ≈ 250 ms each). */
20
+ export declare const AUTH_LIMIT_PER_MINUTE = 20;
21
+ /** Admin tier: privileged operations are rare in normal use, so 30/min is ample and keeps scripted admin abuse bounded. */
22
+ export declare const ADMIN_LIMIT_PER_MINUTE = 30;
23
+ /** Read tier: 600 authenticated reads/min is far above a single human's dashboard/SSE rate and stops listing/polling floods. */
24
+ export declare const READ_LIMIT_PER_MINUTE = 600;
25
+ /** Write tier: 300 mutations/min sits between auth and reads; a collaborative editor stays well under it while scripted floods do not. */
26
+ export declare const WRITE_LIMIT_PER_MINUTE = 300;
27
+ /** Static tier: file-serving reads are cheap; 600/min bounds fs-flood attempts without throttling normal page loads. */
28
+ export declare const STATIC_LIMIT_PER_MINUTE = 600;
29
+ /**
30
+ * Report whether an HTTP method is "safe" (read-only) for tier assignment.
31
+ *
32
+ * Express delivers `req.method` upper-cased, so the lookup is direct; the
33
+ * `toUpperCase` keeps the predicate correct for any caller that passes a
34
+ * mixed-case value (tests, custom servers) without introducing a branch the
35
+ * coverage gate would have to exercise.
36
+ *
37
+ * @param method - The HTTP method string to classify.
38
+ * @returns `true` for `GET`, `HEAD` and `OPTIONS`; `false` for every mutating method.
39
+ */
40
+ export declare function isSafeMethod(method: string): boolean;
41
+ /**
42
+ * Report whether an HTTP method mutates state and therefore belongs in the
43
+ * "write" tier (or, for the CSRF guard, is the kind of request a forged
44
+ * cross-site request would target).
45
+ *
46
+ * @param method - The HTTP method string to classify.
47
+ * @returns `true` for `POST`, `PATCH`, `PUT`, `DELETE`, …; `false` for safe methods.
48
+ */
49
+ export declare function isUnsafeMethod(method: string): boolean;
50
+ /**
51
+ * Resolve the Express `trust proxy` setting from the environment.
52
+ *
53
+ * pm-web runs behind a reverse proxy (Caddy), so `req.ip` is the proxy's
54
+ * address unless Express is told how many `X-Forwarded-For` hops to trust.
55
+ * Getting this wrong collapses every client into one rate-limit bucket
56
+ * (the proxy's IP), which both defeats the limiter and makes the CSRF
57
+ * same-origin check unreliable. This function never returns the boolean
58
+ * `true`: trusting every hop lets any caller spoof `X-Forwarded-For` and
59
+ * bypass per-client limits, and `express-rate-limit` refuses that setting.
60
+ *
61
+ * The default trusts NO hop. That is the only safe default here, because the
62
+ * unsafe direction is silent: every launch path this package documents --
63
+ * `docker run -p 4000:4000`, `npm start`, a pm-host container -- exposes
64
+ * Express directly, and trusting one hop there makes `req.ip` the value the
65
+ * caller wrote in `X-Forwarded-For`. An attacker rotating that header would
66
+ * draw a fresh bucket for every request and bypass all of these limits while
67
+ * the limiter reported itself as enforcing them. A deployment that really is
68
+ * behind a proxy fails in the visible direction instead: every client shares
69
+ * the proxy's address, the limit bites early, and an operator notices.
70
+ *
71
+ * Deployments behind a reverse proxy set `PM_WEB_TRUST_PROXY` to the hop count
72
+ * (`1` for the Caddy deployment), to a comma-separated IP/subnet allowlist, or
73
+ * to `false`/`0` to state the direct case explicitly.
74
+ *
75
+ * @param env - Environment to read `PM_WEB_TRUST_PROXY` from; defaults to
76
+ * `process.env` so production reads the live configuration.
77
+ * @returns A value Express accepts for `app.set("trust proxy", …)`: a
78
+ * non-negative hop count, a boolean `false`, or a comma-separated
79
+ * IP/subnet string.
80
+ */
81
+ export declare function resolveTrustProxy(env?: NodeJS.ProcessEnv): number | boolean | string;
82
+ /**
83
+ * Options accepted by {@link createRateLimiter}.
84
+ *
85
+ * `skip` mirrors `express-rate-limit`'s option of the same name: return
86
+ * `true` to keep a request out of this limiter's bucket, which is how the read
87
+ * and write tiers split a single router by HTTP method without a separate
88
+ * mount per method.
89
+ */
90
+ export interface RateLimiterOptions {
91
+ /** Window length in milliseconds; the quota replenishes after this elapses. */
92
+ readonly windowMs: number;
93
+ /** Maximum requests allowed within the window before the limiter answers 429. */
94
+ readonly limit: number;
95
+ /** Policy name emitted in the `RateLimit-Policy` header for clients. */
96
+ readonly identifier: string;
97
+ /** Optional predicate; returning `true` exempts a request from this limiter. */
98
+ readonly skip?: (req: Request, res: Response) => boolean;
99
+ }
100
+ /**
101
+ * Build a rate-limiting middleware from pm-web's shared configuration.
102
+ *
103
+ * Wraps `express-rate-limit` so every tier emits the same standard
104
+ * `RateLimit-*` headers (draft-6) and disables the legacy `X-RateLimit-*`
105
+ * headers, giving clients one consistent retry contract. The factory is
106
+ * exported so the integration test can build a limiter with a tiny limit and
107
+ * drive a route past it, proving the middleware actually returns 429 rather
108
+ * than merely being registered.
109
+ *
110
+ * @param options - Window, limit, identifier and optional `skip` predicate.
111
+ * @returns An Express middleware that rate-limits its mount.
112
+ */
113
+ export declare function createRateLimiter(options: RateLimiterOptions): RateLimitRequestHandler;
114
+ /**
115
+ * The set of rate-limit middlewares {@link createApp} mounts, one per tier.
116
+ *
117
+ * Each limiter owns an independent in-memory store, so the auth tier, the
118
+ * admin tier, the shared read bucket, the shared write bucket and the static
119
+ * tier never steal quota from one another. A single `read`/`write` pair is
120
+ * shared across all the ordinary API mounts so a client has one aggregate
121
+ * read budget and one aggregate write budget per minute — simpler to reason
122
+ * about than a per-router bucket, and still far above any legitimate
123
+ * collaborative-editing rate.
124
+ */
125
+ export interface TierLimiters {
126
+ /** Tightest tier: credential-checking and OIDC routes (`/api/auth`). */
127
+ readonly auth: RateLimitRequestHandler;
128
+ /** Tight tier: privileged administration routes (`/api/admin`). */
129
+ readonly admin: RateLimitRequestHandler;
130
+ /** Loose tier: authenticated reads across all ordinary API routers. */
131
+ readonly read: RateLimitRequestHandler;
132
+ /** Medium tier: mutations across all ordinary API routers. */
133
+ readonly write: RateLimitRequestHandler;
134
+ /** Loose tier: file-serving routes (`/sw.js`, legal pages, SPA fallback). */
135
+ readonly staticAssets: RateLimitRequestHandler;
136
+ }
137
+ /**
138
+ * Build the five tier limiters from the environment, with production defaults.
139
+ *
140
+ * Each limit is overridable with a `PM_WEB_RATE_LIMIT_*` variable so an
141
+ * operator can tune a tier for their deployment without a code change, and so
142
+ * the test harness can raise the limits high enough that the functional
143
+ * (real-Postgres) suite — which issues many requests per file — is never
144
+ * throttled while the dedicated limiter test exercises an isolated limiter.
145
+ *
146
+ * @param env - Environment to read the overrides from; defaults to `process.env`.
147
+ * @returns The five tier middlewares, each with its own hit-count store.
148
+ */
149
+ export declare function createTierLimiters(env?: NodeJS.ProcessEnv): TierLimiters;