@usequark/quark-create-app 1.27.2 → 1.27.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequark/quark-create-app",
3
- "version": "1.27.2",
3
+ "version": "1.27.3",
4
4
  "description": "CLI to scaffold new Quark projects with Next.js, Prisma, BullMQ, and Railway deployment",
5
5
  "keywords": [
6
6
  "quark",
@@ -118,6 +118,18 @@ ADMIN_PASSWORD=SET_ME
118
118
  # cannot earn a fresh bucket. Boolean-like: true/false/1/0/yes/no/on/off.
119
119
  # TRUST_PROXY_HEADERS=true
120
120
 
121
+ # --- Umami Ingest Proxy & Cloudflare ---
122
+ # The first-party analytics proxy (/s/*) resolves the visitor IP from proxy
123
+ # headers. Unlike x-forwarded-for, CF-Connecting-IP is NOT sanitized by
124
+ # generic proxies (Railway, nginx, Docker pass it through), so trusting it
125
+ # unconditionally would let any client override a legitimate X-Forwarded-For.
126
+ # Default (unset) ignores it. Set to true ONLY when Cloudflare sits in front
127
+ # of the app (it overwrites the header with the true client IP): correct geo
128
+ # and stable Umami sessions behind Cloudflare → Railway. Boolean-like:
129
+ # true/1/yes/on enable; anything else (including unset) disables.
130
+ # Independent of TRUST_PROXY_HEADERS, which still forces "unknown" when false.
131
+ # TRUST_CF_CONNECTING_IP=true
132
+
121
133
  # --- File Storage ---
122
134
  # Default: local filesystem (./uploads). No further config needed for development.
123
135
  STORAGE_PROVIDER=local
@@ -14,9 +14,16 @@
14
14
  * - POST-only — every other method answers 405.
15
15
  * - Header hygiene — `cookie`, `authorization`, and `x-metrics-token` are
16
16
  * never forwarded; only the analytics-relevant headers cross the boundary.
17
- * - `X-Forwarded-For` / `X-Real-IP` are preserved so Umami geolocates the
18
- * visitor, not the Railway egress IP — unless TRUST_PROXY_HEADERS=false,
19
- * in which case "unknown" crosses instead (same rule as proxy.js).
17
+ * - `X-Forwarded-For` / `X-Real-IP` carry the single resolved visitor IP so
18
+ * Umami geolocates the visitor, not the Railway egress IP — unless
19
+ * TRUST_PROXY_HEADERS=false, in which case "unknown" crosses instead (same
20
+ * rule as proxy.js). Resolution prefers `CF-Connecting-IP`, but only with
21
+ * explicit opt-in (`TRUST_CF_CONNECTING_IP=true`, for Cloudflare-fronted
22
+ * deployments): the header takes precedence yet generic proxies pass it
23
+ * through unsanitized, so unconditional trust would let any client override
24
+ * a legitimate X-Forwarded-For. `CF-Connecting-IP` passthrough follows the
25
+ * same opt-in — never when untrusted. Tracker metadata (`x-umami-cache`) is forwarded; `cookie`,
26
+ * `authorization`, and `x-metrics-token` never are.
20
27
  * - 1 MB body cap enforced while streaming (a missing/lying Content-Length
21
28
  * cannot force full buffering) + 5s upstream timeout bound abuse cost.
22
29
  * - `Cache-Control: no-store` on every response; upstream `Set-Cookie` is
@@ -35,7 +42,10 @@
35
42
  import { createLogger } from "@usequark/quark-core/core";
36
43
  import { NextResponse } from "next/server";
37
44
  import { getUmamiUpstreamBase } from "@/lib/analytics/umami-config.js";
38
- import { shouldTrustProxyHeaders } from "@/lib/proxy-auth.js";
45
+ import {
46
+ shouldTrustCfConnectingIp,
47
+ shouldTrustProxyHeaders,
48
+ } from "@/lib/proxy-auth.js";
39
49
 
40
50
  const logger = createLogger("umami-proxy");
41
51
 
@@ -54,8 +64,8 @@ function methodNotAllowed() {
54
64
  );
55
65
  }
56
66
 
57
- // Fail-open without losing replay data. Tracker (`send`) and web-vitals
58
- // (`collect`) beacons are fire-and-forget with no retry path, so they keep
67
+ // Fail-open without losing replay data. Tracker, web-vitals, and legacy
68
+ // `collect` beacons are fire-and-forget with no retry path, so they keep
59
69
  // `{ ok: true }` to avoid any client-side retry storm against a struggling
60
70
  // upstream. Replay (`record`) has a restore/retry path keyed on
61
71
  // `body.ok === false` in postReplayBatch: answering `ok: true` there would
@@ -72,7 +82,26 @@ function failOpenResponse(endpoint) {
72
82
  });
73
83
  }
74
84
 
75
- function resolveClientIp(request) {
85
+ function resolveClientIp(request, { trustCfConnectingIp = false } = {}) {
86
+ // Preference (only when the operator opted in via TRUST_CF_CONNECTING_IP):
87
+ // Cloudflare overwrites CF-Connecting-IP with the true client IP and
88
+ // Railway passes it through, while Railway REPLACES X-Forwarded-For with
89
+ // the Cloudflare edge address. Reading XFF alone therefore misattributes
90
+ // every visitor behind Cloudflare — wrong geo, and rotating edge IPs
91
+ // change Umami's hash(IP, UA, site) session identity between requests of
92
+ // one visit, splitting it into duplicate sessions.
93
+ //
94
+ // The opt-in is load-bearing, not bureaucracy: CF-Connecting-IP takes
95
+ // precedence over X-Forwarded-For, and most proxies pass it through
96
+ // untouched — so trusting it unconditionally would let any client override
97
+ // an otherwise-legitimate XFF with an arbitrary IP. Only Cloudflare
98
+ // itself sanitizes this header, hence explicit opt-in (and
99
+ // TRUST_PROXY_HEADERS=false still forces "unknown" at the call site).
100
+ const cloudflareIp = trustCfConnectingIp
101
+ ? request.headers.get("cf-connecting-ip")?.trim()
102
+ : null;
103
+ if (cloudflareIp) return cloudflareIp;
104
+
76
105
  const forwardedFor = request.headers.get("x-forwarded-for");
77
106
  if (forwardedFor) {
78
107
  const client = forwardedFor
@@ -187,23 +216,36 @@ export async function POST(request, { params } = {}) {
187
216
  // TRUST_PROXY_HEADERS=false the app is directly reachable, the headers
188
217
  // are attacker-controlled, and forwarding them would poison Umami geo.
189
218
  // "unknown" (never client bytes) crosses the boundary instead.
219
+ // CF-Connecting-IP additionally needs TRUST_CF_CONNECTING_IP=true:
220
+ // unlike XFF it is not sanitized by generic proxies, so trusting it
221
+ // unconditionally would let a client override a legitimate XFF.
190
222
  const trustProxyHeaders = shouldTrustProxyHeaders();
191
- const incomingForwardedFor = trustProxyHeaders
192
- ? request.headers.get("x-forwarded-for")
193
- : null;
194
- const clientIp = trustProxyHeaders ? resolveClientIp(request) : "unknown";
195
- const forwardedFor = incomingForwardedFor?.trim()
196
- ? incomingForwardedFor
197
- : clientIp;
223
+ const trustCfConnectingIp = trustProxyHeaders && shouldTrustCfConnectingIp();
224
+ const clientIp = trustProxyHeaders
225
+ ? resolveClientIp(request, { trustCfConnectingIp })
226
+ : "unknown";
198
227
 
199
228
  const forwardHeaders = {
200
229
  "Content-Type": "application/json",
201
- "X-Forwarded-For": forwardedFor,
202
- "X-Real-IP": trustProxyHeaders
203
- ? request.headers.get("x-real-ip")?.trim() || clientIp
204
- : clientIp,
230
+ // Single stable IP, never the incoming chain: Umami hashes
231
+ // (IP, UA, site) into its session identity, so a rotating edge IP
232
+ // anywhere in a forwarded chain splits one visit into duplicate
233
+ // sessions (and geolocates the edge, not the visitor). Chain entries
234
+ // beyond the client are proxies — no analytics value, only leak.
235
+ "X-Forwarded-For": clientIp,
236
+ "X-Real-IP": clientIp,
205
237
  };
206
238
 
239
+ // Umami reads cf-connecting-ip before X-Forwarded-For, and behind
240
+ // Cloudflare it is the only header carrying the true client IP (Railway
241
+ // replaces XFF with the edge address). Pass it through — but only under
242
+ // the same opt-in that allowed it to win resolution above, and never when
243
+ // untrusted, when it is client-set bytes like every other proxy header.
244
+ if (trustCfConnectingIp) {
245
+ const cloudflareIp = request.headers.get("cf-connecting-ip")?.trim();
246
+ if (cloudflareIp) forwardHeaders["CF-Connecting-IP"] = cloudflareIp;
247
+ }
248
+
207
249
  const userAgent = request.headers.get("user-agent");
208
250
  if (userAgent) forwardHeaders["User-Agent"] = userAgent;
209
251
 
@@ -58,6 +58,10 @@ export const UMAMI_PROXY_BASE = "/s";
58
58
  export const UMAMI_PROXY_SCRIPT_PATH = "/s/script.js";
59
59
  export const UMAMI_PROXY_HOST_URL = "/s";
60
60
  export const UMAMI_PROXY_SEND_PATH = "/s/api/send";
61
+ // Legacy alias for pre-v2 Umami (whose ingest lived at /api/collect). Nothing
62
+ // in the scaffold posts here anymore — web-vitals uses the send schema — but
63
+ // the pinned path costs nothing to keep forwarding. Do not remove without
64
+ // checking supported upstream versions.
61
65
  export const UMAMI_PROXY_COLLECT_PATH = "/s/api/collect";
62
66
  export const UMAMI_PROXY_RECORD_PATH = "/s/api/record";
63
67
 
@@ -9,7 +9,9 @@
9
9
  * Privacy guarantees:
10
10
  * - Zero user data: no email, name, ID, token, or cookie values.
11
11
  * - Layout timings only: metric name, numeric value, rating string,
12
- * and a sanitised URL path (origin stripped, never full URL with query).
12
+ * a sanitised URL path (origin stripped, never full URL with query),
13
+ * and the bare hostname (as the stock tracker sends; keeps these rows
14
+ * out of the NULL-hostname group in Umami's sessions list).
13
15
  * - No referrer, no user-agent, no IP collected by this script.
14
16
  * - Uses sendBeacon for reliable delivery without blocking the page.
15
17
  *
@@ -57,12 +59,22 @@ function sanitiseUrl(url) {
57
59
  }
58
60
 
59
61
  /**
60
- * Dispatches a single CWV observation to the Umami collect endpoint
61
- * as a custom event (event_type = 2). The payload is fully anonymous.
62
+ * Dispatches a single CWV observation to Umami's send endpoint as a custom
63
+ * event (`name: "cwv"`, metrics in `data`). The payload is fully anonymous.
64
+ *
65
+ * Shape matters: Umami derives the event type server-side (`name` present →
66
+ * custom event) and persists `payload.name`/`payload.data` — `event_name`,
67
+ * `event_type`, and `event_data` keys are ignored, and posting anywhere
68
+ * other than `/api/send` (there is no `/api/collect` in Umami v2+) silently
69
+ * drops the beacon while recording nothing. Both mistakes shipped here
70
+ * before and were caught by reading Umami's send route, not by any test.
71
+ *
72
+ * Exported for tests: the browser-only shape (window/navigator) cannot be
73
+ * covered by observation in unit tests.
62
74
  *
63
75
  * @param {{ name: string, value: number, rating: string, navigationType?: string }} metric
64
76
  */
65
- function dispatchMetric(metric) {
77
+ export function dispatchMetric(metric) {
66
78
  const config = getUmamiClientConfig();
67
79
  if (!config.enabled) return;
68
80
 
@@ -70,15 +82,15 @@ function dispatchMetric(metric) {
70
82
  type: "event",
71
83
  payload: {
72
84
  website: config.websiteId,
73
- event_name: "cwv",
74
- event_type: 2, // custom event
75
- url: sanitiseUrl(window.location.href),
76
- event_data: {
85
+ name: "cwv",
86
+ data: {
77
87
  metric_name: metric.name,
78
88
  metric_value: Math.round(metric.value * 1000) / 1000, // round to 3dp
79
89
  metric_rating: metric.rating,
80
90
  navigation_type: metric.navigationType || "navigate",
81
91
  },
92
+ url: sanitiseUrl(window.location.href),
93
+ hostname: window.location.hostname,
82
94
  },
83
95
  };
84
96
 
@@ -86,7 +98,7 @@ function dispatchMetric(metric) {
86
98
 
87
99
  // First-party ingest: same-origin POSTs survive blocklists that match the
88
100
  // upstream analytics host as third-party.
89
- const endpoint = config.proxyCollectUrl || `${config.url}/api/collect`;
101
+ const endpoint = config.proxySendUrl || `${config.url}/api/send`;
90
102
 
91
103
  // Use sendBeacon for reliability during page lifecycle transitions.
92
104
  if (navigator.sendBeacon) {
@@ -140,3 +140,33 @@ export function shouldTrustProxyHeaders() {
140
140
  const raw = process.env.TRUST_PROXY_HEADERS?.trim().toLowerCase();
141
141
  return !(raw && PROXY_HEADERS_FALSY_VALUES.has(raw));
142
142
  }
143
+
144
+ /**
145
+ * Boolean-like values that opt into trusting `CF-Connecting-IP`. Deliberately
146
+ * the mirror of the proxy-headers convention: "explicit true wins", default
147
+ * off. `CF-Connecting-IP` is only overwritten by Cloudflare itself — unlike
148
+ * `X-Forwarded-For`, most proxies (Railway, nginx, Docker) pass it through
149
+ * untouched. Trusting it unconditionally would let any client override an
150
+ * otherwise-legitimate `X-Forwarded-For` with an arbitrary IP, so it stays
151
+ * ignored unless the operator confirms Cloudflare is in the path.
152
+ */
153
+ const CF_CONNECTING_IP_TRUTHY_VALUES = new Set(["true", "1", "yes", "on"]);
154
+
155
+ /**
156
+ * Whether `CF-Connecting-IP` may identify the client in the Umami ingest
157
+ * proxy (resolution preference and upstream passthrough).
158
+ *
159
+ * Default `false`: the header is ignored entirely. Set
160
+ * `TRUST_CF_CONNECTING_IP=true` only when Cloudflare sits in front of the
161
+ * app (it overwrites the header with the true client IP on every request).
162
+ * Independently of this flag, `TRUST_PROXY_HEADERS=false` still forces
163
+ * `"unknown"` — distrust always wins over trust.
164
+ *
165
+ * Read lazily (per request) like `shouldTrustProxyHeaders`.
166
+ *
167
+ * @returns {boolean}
168
+ */
169
+ export function shouldTrustCfConnectingIp() {
170
+ const raw = process.env.TRUST_CF_CONNECTING_IP?.trim().toLowerCase();
171
+ return !!raw && CF_CONNECTING_IP_TRUTHY_VALUES.has(raw);
172
+ }