@unbrained/pm-web 2026.8.17 → 2026.8.29

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,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ### Fixed
6
+
7
+ - 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))
8
+
9
+ ## 2026.8.29 - 2026-08-29
10
+
11
+ ### Fixed
12
+
13
+ - 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))
14
+
15
+ ## 2026.8.28 - 2026-08-28
16
+
17
+ ### Fixed
18
+
19
+ - 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))
20
+ - 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))
21
+ - 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))
22
+ - 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))
23
+ - 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))
24
+ - 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))
25
+ - Fix release publish-before-protected-main-push ordering ([pm-web-8l6j](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-8l6j.toon))
26
+
27
+ ### Security
28
+
29
+ - The identity gate deadlocks the one remediation its own failure message prescribes ([pm-web-1ggj](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-1ggj.toon))
30
+
3
31
  ## 2026.8.17 - 2026-08-17
4
32
 
5
33
  ### Fixed
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 |
@@ -149,6 +150,19 @@ within each Node process, so high-throughput installations should run multiple
149
150
  pm-web replicas behind a shared PostgreSQL realtime bus. Independent replicas
150
151
  converge through PostgreSQL notifications and the mutation-event watcher.
151
152
 
153
+ Whole-project graph fallback, board, local search, iCalendar, and export reads
154
+ use the SDK's high-level `listAllComplete` operation. pm-web accepts those rows
155
+ only after the shared SDK certificate and supplemental 2026.8.21 source,
156
+ omission, output-receipt, and budget checks all agree. The public `/pm/list-all`
157
+ HTTP compatibility route remains deliberately paginated for interactive clients,
158
+ but invokes canonical `list --all` internally; consumers that need the whole
159
+ workspace must use a complete-read endpoint rather than assembling a page as if
160
+ it were the corpus. The standalone server exact-pins pm CLI/SDK 2026.8.21, and
161
+ the extension manifest refuses older hosts through the same compatibility floor.
162
+ Commands that render their own text or JSON (`web status`, `web stop`, and
163
+ `web doctor`) return the public SDK output-suppression marker, so the host never
164
+ appends a second payload to stdout.
165
+
152
166
  Optional OIDC uses Authorization Code flow with PKCE, provider discovery/JWKS,
153
167
  signed state cookies, and issuer/subject identity mapping. It is disabled when
154
168
  no OIDC variables are present and production startup fails closed on partial or
@@ -186,13 +200,29 @@ MIT
186
200
 
187
201
  ## Release Automation
188
202
 
189
- This package is release-ready for GitHub, npm, and Bun-compatible installs. CI runs type checking, build, production dependency audit, package packing, Bun install verification, and pm-changelog validation. The daily release workflow publishes only when commits exist after the latest release tag and uses pm-changelog to generate CHANGELOG.md and GitHub release notes.
203
+ CI runs type checking, exact Node 22.18.0 and current-Node matrices, real
204
+ PostgreSQL integration tests, the configured coverage gate, complete docstring
205
+ coverage, production dependency audit, package packing, fresh packed `npx` and
206
+ `bunx` install-and-command acceptance, immutable workflow-action checks, and
207
+ pm-changelog validation. The daily release
208
+ workflow publishes at most once when commits exist after the latest release tag
209
+ and uses pm-changelog for both `CHANGELOG.md` and GitHub release notes.
210
+
211
+ The current package is **not** approved for a new release: exact all-source
212
+ 100/100/100/100 coverage remains open in
213
+ [`pm-web-9ulj`](.agents/pm/epics/pm-web-9ulj.toon) and
214
+ [`pm-web-ulgy`](.agents/pm/tasks/pm-web-ulgy.toon), while reachable-history
215
+ privacy authorization is tracked in
216
+ [`pm-web-priv`](.agents/pm/issues/pm-web-priv.toon) and
217
+ [GitHub issue #96](https://github.com/unbraind/pm-web/issues/96). Passing the
218
+ configured gate is evidence for the measured source set, not those independent
219
+ release approvals.
190
220
 
191
221
  ## New data endpoints (kanban board & search)
192
222
 
193
- The pm data API now exposes board and search views, both driven by the
194
- workspace's live `pm contracts` schema (so they reflect the installed pm CLI +
195
- extensions):
223
+ The pm data API exposes board and search views through the certified
224
+ complete-read contract described above. The board also uses the workspace's
225
+ live `pm contracts` statuses for its columns:
196
226
 
197
227
  - `GET /api/projects/:projectId/pm/board` — items grouped into kanban columns by
198
228
  the workspace's runtime statuses (unlisted statuses fall into `(other)`).
package/dist/app.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type Express } from "express";
2
+ import { type HealthProbeDeps } from "./health.ts";
2
3
  /**
3
4
  * Legal pages served as standalone HTML (not part of the SPA bundle).
4
5
  *
@@ -34,10 +35,33 @@ export declare const LEGAL_REDIRECTS: Record<string, string>;
34
35
  * @returns The real, resolved directory path holding the legal HTML files.
35
36
  */
36
37
  export declare function resolveLegalPagesDir(env?: NodeJS.ProcessEnv): string;
38
+ /**
39
+ * Optional dependencies `createApp` can wire into the application.
40
+ *
41
+ * Currently only the {@link HealthProbeDeps} for the real `/healthz` handler is
42
+ * supported; the object is kept as a bag so further production-only wiring
43
+ * (e.g. a logger) can be added later without another signature change.
44
+ */
45
+ export interface CreateAppDeps {
46
+ /**
47
+ * Health-probe dependencies for the real `/healthz` handler. When supplied,
48
+ * `createApp` mounts {@link createHealthHandler}, which probes PostgreSQL and
49
+ * the projects volume and answers 200/503 accordingly. When omitted, the
50
+ * route answers 503 `ok:false` (see the comment at the route) so a
51
+ * misconfigured deployment can never report healthy while its dependencies
52
+ * are down — the exact failure this handler was written to close.
53
+ */
54
+ readonly health?: HealthProbeDeps;
55
+ }
37
56
  /**
38
57
  * Build the Express application with all middleware, static assets, legal
39
58
  * page routes, API routes and the SPA fallback — but WITHOUT touching the
40
59
  * database or binding a port. Splitting this out from server.ts keeps the
41
60
  * HTTP surface unit-testable without a running PostgreSQL instance.
61
+ *
62
+ * @param deps - Optional production wiring. Pass `health` to mount the real
63
+ * probing `/healthz` handler; omit it for tests that only need the HTTP
64
+ * surface (the route then answers 503 `ok:false`, never `ok:true`).
65
+ * @returns The configured Express application, not yet listening on a port.
42
66
  */
43
- export declare function createApp(): Express;
67
+ export declare function createApp(deps?: CreateAppDeps): Express;
package/dist/app.js CHANGED
@@ -3,6 +3,7 @@ import cookieParser from "cookie-parser";
3
3
  import path from "node:path";
4
4
  import { accessSync, constants, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { createHealthHandler } from "./health.js";
6
7
  import { authRouter } from "./routes/auth.js";
7
8
  import { oidcRouter } from "./routes/oidc.js";
8
9
  import { projectsRouter } from "./routes/projects.js";
@@ -12,6 +13,8 @@ import { groupsRouter } from "./routes/groups.js";
12
13
  import { sharesRouter, sharedWithMeRouter } from "./routes/sharing.js";
13
14
  import { githubRouter } from "./routes/github.js";
14
15
  import { adminRouter } from "./routes/admin.js";
16
+ import { createTierLimiters, resolveTrustProxy } from "./rate-limit.js";
17
+ import { csrfProtection } from "./csrf.js";
15
18
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
19
  const PUBLIC_DIR = path.resolve(__dirname, "..", "public");
17
20
  /**
@@ -95,13 +98,31 @@ export function resolveLegalPagesDir(env = process.env) {
95
98
  * page routes, API routes and the SPA fallback — but WITHOUT touching the
96
99
  * database or binding a port. Splitting this out from server.ts keeps the
97
100
  * HTTP surface unit-testable without a running PostgreSQL instance.
101
+ *
102
+ * @param deps - Optional production wiring. Pass `health` to mount the real
103
+ * probing `/healthz` handler; omit it for tests that only need the HTTP
104
+ * surface (the route then answers 503 `ok:false`, never `ok:true`).
105
+ * @returns The configured Express application, not yet listening on a port.
98
106
  */
99
- export function createApp() {
107
+ export function createApp(deps) {
100
108
  const app = express();
101
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);
102
123
  app.use(express.json({ limit: "1mb" }));
103
124
  app.use(cookieParser());
104
- app.get("/sw.js", (_req, res) => {
125
+ app.get("/sw.js", limiters.staticAssets, (_req, res) => {
105
126
  res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate");
106
127
  res.setHeader("Pragma", "no-cache");
107
128
  res.setHeader("Expires", "0");
@@ -128,14 +149,32 @@ export function createApp() {
128
149
  return "unknown";
129
150
  }
130
151
  })();
131
- app.get("/healthz", (_req, res) => res.json({ ok: true, version: PM_WEB_VERSION }));
152
+ if (deps?.health) {
153
+ // Production wiring: probe PostgreSQL and the projects volume before
154
+ // answering. `server.ts` always supplies these dependencies, so the
155
+ // deployed service reports healthy only when its hard dependencies are
156
+ // actually reachable.
157
+ app.get("/healthz", createHealthHandler(deps.health));
158
+ }
159
+ else {
160
+ // No health-probe dependencies were supplied, so this route cannot probe
161
+ // PostgreSQL or the projects volume. Answering `ok:true` here would
162
+ // reproduce the original bug this handler exists to close: pm-web has
163
+ // previously served frozen data for two days while `/healthz` reported
164
+ // healthy. A route that cannot probe must not claim `ok:true`, so the
165
+ // unconfigured default answers 503 with the version only. Production
166
+ // (`server.ts`) always supplies the real dependencies; this branch is
167
+ // reached only by tests that exercise the HTTP surface without a database
168
+ // and by misconfigured deployments, which must fail loud rather than lie.
169
+ app.get("/healthz", (_req, res) => res.status(503).json({ ok: false, version: PM_WEB_VERSION }));
170
+ }
132
171
  const legalPages = new Set(LEGAL_PAGES);
133
172
  Object.entries(LEGAL_REDIRECTS).forEach(([from, to]) => {
134
173
  app.get(from, (_req, res) => {
135
174
  res.redirect(308, to);
136
175
  });
137
176
  });
138
- 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) => {
139
178
  // Non-strict routing also matches trailing-slash variants (/terms/), so
140
179
  // normalize before the whitelist lookup instead of 404ing on them.
141
180
  const page = req.path.replace(/\/+$/, "").slice(1);
@@ -146,24 +185,45 @@ export function createApp() {
146
185
  res.setHeader("Cache-Control", "no-store");
147
186
  res.sendFile(path.join(legalPagesDir, `${page}.html`));
148
187
  });
149
- // 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);
150
209
  app.use("/api/auth", oidcRouter);
151
210
  app.use("/api/auth", authRouter);
211
+ app.use("/api/projects", limiters.read, limiters.write);
152
212
  app.use("/api/projects", projectsRouter);
153
213
  app.use("/api/projects/:projectId/pm", pmRouter);
154
214
  app.use("/api/projects/:projectId/extensions", extensionsRouter);
155
- app.use("/api/groups", groupsRouter);
156
215
  app.use("/api/projects/:id/shares", sharesRouter);
157
- app.use("/api/shared", sharedWithMeRouter);
158
216
  app.use("/api/projects/:id/github", githubRouter);
159
- 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);
160
220
  // Unknown API routes get a JSON 404 instead of falling through to the SPA
161
221
  // shell, which would hand HTML to API clients expecting JSON.
162
222
  app.all("/api/{*splat}", (_req, res) => {
163
223
  res.status(404).json({ error: "Not found" });
164
224
  });
165
225
  // SPA fallback — serve index.html for all non-API routes
166
- app.get("/{*splat}", (_req, res) => {
226
+ app.get("/{*splat}", limiters.staticAssets, (_req, res) => {
167
227
  res.sendFile(path.join(PUBLIC_DIR, "index.html"));
168
228
  });
169
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,48 @@
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 sites, using the unforgeable
16
+ * `Sec-Fetch-Site` header first and the `Origin` header as a 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. When it is absent
20
+ * (older browsers, non-browser clients) the `Origin` header is compared
21
+ * against the request's own `Host`: a browser always sends `Origin` on
22
+ * unsafe requests, so a present, mismatched `Origin` is also conclusive. A
23
+ * missing `Origin` means the request is not browser-initiated and therefore
24
+ * cannot be a CSRF attack, so it is allowed. A malformed `Origin` is treated
25
+ * as not-cross-site rather than blocked, so the guard never produces a false
26
+ * positive that would block a legitimate (if quirky) client.
27
+ *
28
+ * @param req - The Express request to inspect.
29
+ * @returns `true` when the request is provably cross-site and should be blocked.
30
+ */
31
+ export declare function isCrossSiteRequest(req: Request): boolean;
32
+ /**
33
+ * Build the CSRF protection middleware.
34
+ *
35
+ * On every request it (re)issues the {@link CSRF_COOKIE_NAME} token cookie when
36
+ * absent, and on every **cookie-authenticated, state-changing** request it
37
+ * blocks provably cross-site callers with 403. Safe methods and unauthenticated
38
+ * requests pass through, so login/register (no session cookie) and reads are
39
+ * unaffected. The same-origin check relies on browser-fetch-metadata headers
40
+ * that are absent from Node's `fetch`, so the real-Postgres route suite —
41
+ * which drives the app with cookie-authenticated `fetch` calls and no `Origin`
42
+ * — is never blocked, while a real browser CSRF attempt (which always sends
43
+ * `Sec-Fetch-Site: cross-site` or a foreign `Origin`) is.
44
+ *
45
+ * @returns An Express middleware implementing the double-submit-cookie plus
46
+ * same-origin defence.
47
+ */
48
+ export declare function csrfProtection(): RequestHandler;
package/dist/csrf.js ADDED
@@ -0,0 +1,124 @@
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 sites. `none` is a user-initiated navigation (no referrer) and is safe;
18
+ * `same-origin` and `same-site` are, by definition, not cross-site. Any other
19
+ * value (`cross-site`) is a forgery attempt and is blocked.
20
+ */
21
+ const SAME_FETCH_SITES = new Set(["same-origin", "same-site", "none"]);
22
+ /**
23
+ * Read the `pm_token` session cookie from a request that has already been
24
+ * through `cookie-parser`.
25
+ *
26
+ * The CSRF guard only enforces same-origin on requests that are actually
27
+ * authenticated with the session cookie: a cross-site `POST /api/auth/login`
28
+ * carries no `pm_token` (the victim has not logged in from the attacker's
29
+ * page), so it cannot act on the victim's behalf and need not be blocked.
30
+ * Bearer-token API clients (which send `Authorization` and no cookie) are
31
+ * likewise exempt, which is why the guard keys on the cookie and not on
32
+ * `requireAuth`.
33
+ *
34
+ * @param req - The Express request, post-`cookie-parser`.
35
+ * @returns `true` when a `pm_token` session cookie is present.
36
+ */
37
+ function hasSessionCookie(req) {
38
+ const cookies = req.cookies;
39
+ return Boolean(cookies && cookies.pm_token);
40
+ }
41
+ /**
42
+ * Decide whether an unsafe request crossed sites, using the unforgeable
43
+ * `Sec-Fetch-Site` header first and the `Origin` header as a fallback.
44
+ *
45
+ * `Sec-Fetch-Site` is set by the browser fetch metadata spec and cannot be
46
+ * spoofed from JavaScript, so `cross-site` is conclusive. When it is absent
47
+ * (older browsers, non-browser clients) the `Origin` header is compared
48
+ * against the request's own `Host`: a browser always sends `Origin` on
49
+ * unsafe requests, so a present, mismatched `Origin` is also conclusive. A
50
+ * missing `Origin` means the request is not browser-initiated and therefore
51
+ * cannot be a CSRF attack, so it is allowed. A malformed `Origin` is treated
52
+ * as not-cross-site rather than blocked, so the guard never produces a false
53
+ * positive that would block a legitimate (if quirky) client.
54
+ *
55
+ * @param req - The Express request to inspect.
56
+ * @returns `true` when the request is provably cross-site and should be blocked.
57
+ */
58
+ export function isCrossSiteRequest(req) {
59
+ const site = req.headers["sec-fetch-site"];
60
+ if (site === "cross-site")
61
+ return true;
62
+ if (typeof site === "string" && SAME_FETCH_SITES.has(site))
63
+ return false;
64
+ const origin = req.headers["origin"];
65
+ if (typeof origin !== "string" || origin === "")
66
+ return false;
67
+ let originHost;
68
+ try {
69
+ originHost = new URL(origin).host;
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ return originHost !== (req.get("host") ?? "");
75
+ }
76
+ /**
77
+ * Set the double-submit CSRF token cookie when the request does not already
78
+ * carry one, so a SPA can read and replay it.
79
+ *
80
+ * The cookie is `SameSite=Lax`, `Secure` in production and **not** `HttpOnly`,
81
+ * the last being the point: the SPA needs to read it to send it back as a
82
+ * header. It carries no secret — it is a random token whose only job is to be
83
+ * the same on the way out and the way back.
84
+ *
85
+ * @param req - The Express request, post-`cookie-parser`.
86
+ * @param res - The Express response to attach the cookie to.
87
+ */
88
+ function ensureCsrfCookie(req, res) {
89
+ const cookies = req.cookies;
90
+ if (cookies?.[CSRF_COOKIE_NAME])
91
+ return;
92
+ res.cookie(CSRF_COOKIE_NAME, crypto.randomBytes(24).toString("base64url"), {
93
+ sameSite: "lax",
94
+ httpOnly: false,
95
+ secure: process.env.NODE_ENV === "production",
96
+ });
97
+ }
98
+ /**
99
+ * Build the CSRF protection middleware.
100
+ *
101
+ * On every request it (re)issues the {@link CSRF_COOKIE_NAME} token cookie when
102
+ * absent, and on every **cookie-authenticated, state-changing** request it
103
+ * blocks provably cross-site callers with 403. Safe methods and unauthenticated
104
+ * requests pass through, so login/register (no session cookie) and reads are
105
+ * unaffected. The same-origin check relies on browser-fetch-metadata headers
106
+ * that are absent from Node's `fetch`, so the real-Postgres route suite —
107
+ * which drives the app with cookie-authenticated `fetch` calls and no `Origin`
108
+ * — is never blocked, while a real browser CSRF attempt (which always sends
109
+ * `Sec-Fetch-Site: cross-site` or a foreign `Origin`) is.
110
+ *
111
+ * @returns An Express middleware implementing the double-submit-cookie plus
112
+ * same-origin defence.
113
+ */
114
+ export function csrfProtection() {
115
+ return (req, res, next) => {
116
+ ensureCsrfCookie(req, res);
117
+ if (!isUnsafeMethod(req.method) || !hasSessionCookie(req) || !isCrossSiteRequest(req)) {
118
+ next();
119
+ return;
120
+ }
121
+ res.status(403).json({ error: "Cross-origin request blocked" });
122
+ };
123
+ }
124
+ //# sourceMappingURL=csrf.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"csrf.js","sourceRoot":"","sources":["../src/csrf.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,aAAa,CAAC;AAEjC,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEjD;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,YAAY,CAAC;AAE7C;;;;;GAKG;AACH,MAAM,gBAAgB,GAAG,IAAI,GAAG,CAAC,CAAC,aAAa,EAAE,WAAW,EAAE,MAAM,CAAC,CAAC,CAAC;AAEvE;;;;;;;;;;;;;;GAcG;AACH,SAAS,gBAAgB,CAAC,GAAY;IACpC,MAAM,OAAO,GAAI,GAAkE,CAAC,OAAO,CAAC;IAC5F,OAAO,OAAO,CAAC,OAAO,IAAI,OAAO,CAAC,QAAQ,CAAC,CAAC;AAC9C,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,kBAAkB,CAAC,GAAY;IAC7C,MAAM,IAAI,GAAG,GAAG,CAAC,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAC3C,IAAI,IAAI,KAAK,YAAY;QAAE,OAAO,IAAI,CAAC;IACvC,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,gBAAgB,CAAC,GAAG,CAAC,IAAI,CAAC;QAAE,OAAO,KAAK,CAAC;IACzE,MAAM,MAAM,GAAG,GAAG,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;IACrC,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,KAAK,EAAE;QAAE,OAAO,KAAK,CAAC;IAC9D,IAAI,UAAkB,CAAC;IACvB,IAAI,CAAC;QACH,UAAU,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC;IACpC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;IACD,OAAO,UAAU,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;AAChD,CAAC;AAED;;;;;;;;;;;GAWG;AACH,SAAS,gBAAgB,CAAC,GAAY,EAAE,GAAa;IACnD,MAAM,OAAO,GAAI,GAAkE,CAAC,OAAO,CAAC;IAC5F,IAAI,OAAO,EAAE,CAAC,gBAAgB,CAAC;QAAE,OAAO;IACxC,GAAG,CAAC,MAAM,CAAC,gBAAgB,EAAE,MAAM,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,EAAE;QACzE,QAAQ,EAAE,KAAK;QACf,QAAQ,EAAE,KAAK;QACf,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY;KAC9C,CAAC,CAAC;AACL,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,cAAc;IAC5B,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;QACxB,gBAAgB,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;QAC3B,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,gBAAgB,CAAC,GAAG,CAAC,IAAI,CAAC,kBAAkB,CAAC,GAAG,CAAC,EAAE,CAAC;YACtF,IAAI,EAAE,CAAC;YACP,OAAO;QACT,CAAC;QACD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,8BAA8B,EAAE,CAAC,CAAC;IAClE,CAAC,CAAC;AACJ,CAAC"}
@@ -0,0 +1,84 @@
1
+ import type { RequestHandler } from "express";
2
+ /**
3
+ * Minimum structural type a PostgreSQL pool must satisfy for the health probe.
4
+ *
5
+ * The real `pg.Pool` satisfies this, and tests can substitute any object whose
6
+ * `query` accepts a SQL string and returns a promise — so the probe runs
7
+ * through the same pool the service uses in production without forcing tests
8
+ * to construct a real database connection.
9
+ */
10
+ export interface Queryable {
11
+ /** Execute a SQL string and return rows. */
12
+ query(text: string): Promise<{
13
+ rows: unknown[];
14
+ }>;
15
+ }
16
+ /** Status reported for one dependency in the health response. */
17
+ export interface DependencyStatus {
18
+ /** Whether the dependency responded successfully. */
19
+ readonly ok: boolean;
20
+ /** Round-trip latency in milliseconds. */
21
+ readonly latency_ms: number;
22
+ /** Human-readable failure reason; omitted on success. */
23
+ readonly error?: string;
24
+ /**
25
+ * Whether the dependency was configured (env var present). Only meaningful
26
+ * for soft dependencies that may legitimately be absent; hard dependencies
27
+ * are always treated as configured.
28
+ */
29
+ readonly configured?: boolean;
30
+ }
31
+ /** A named soft-dependency probe whose result does not affect the status code. */
32
+ export interface SoftProbe {
33
+ /** Key under `dependencies` in the response. */
34
+ readonly name: string;
35
+ /** Async function returning the dependency status. */
36
+ readonly probe: () => Promise<DependencyStatus>;
37
+ }
38
+ /** Dependencies the health handler needs to probe. */
39
+ export interface HealthProbeDeps {
40
+ /** PostgreSQL pool — probed with `SELECT 1` (hard dependency). */
41
+ readonly pool: Queryable;
42
+ /** Host-mounted projects root — probed for writability (hard dependency). */
43
+ readonly projectsRoot: string;
44
+ /** Package version, resolved once at boot from `package.json`. */
45
+ readonly version: string;
46
+ /** Soft-dependency probes; their results are reported but never cause 503. */
47
+ readonly softProbes?: ReadonlyArray<SoftProbe>;
48
+ /**
49
+ * Timing overrides, in milliseconds.
50
+ *
51
+ * Production never sets these. They exist because the behaviour that matters
52
+ * here -- what happens on the second cooldown expiry of a stall that has not
53
+ * ended -- is otherwise only reachable by a test that waits over half a
54
+ * minute, and a guard nothing executes is how a pool-drain protection stops
55
+ * working without anyone noticing.
56
+ */
57
+ readonly timing?: {
58
+ /** Overrides {@link PROBE_TIMEOUT_MS}. */
59
+ readonly probeTimeoutMs?: number;
60
+ /** Overrides {@link CACHE_TTL_MS}. */
61
+ readonly cacheTtlMs?: number;
62
+ /** Overrides {@link POOL_COOLDOWN_MS}. */
63
+ readonly poolCooldownMs?: number;
64
+ };
65
+ }
66
+ /** Shape of the `/healthz` response body (backward compatible: `ok`, `version`). */
67
+ export interface HealthResult {
68
+ /** `true` only when every hard dependency is reachable. */
69
+ readonly ok: boolean;
70
+ /** Package version from `package.json`. */
71
+ readonly version: string;
72
+ /** Per-dependency breakdown. */
73
+ readonly dependencies: Readonly<Record<string, DependencyStatus>>;
74
+ }
75
+ /**
76
+ * Build the Express `/healthz` request handler.
77
+ *
78
+ * The handler probes every hard dependency in parallel, adds soft-dependency
79
+ * results, caches the outcome for {@link CACHE_TTL_MS} milliseconds, and
80
+ * responds with HTTP 200 (`ok: true`) or 503 (`ok: false`). It never throws:
81
+ * each probe catches its own errors, and the outer `try`/`catch` is a
82
+ * defensive last resort.
83
+ */
84
+ export declare function createHealthHandler(deps: HealthProbeDeps): RequestHandler;