mango-cms 0.3.27 → 0.3.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.
@@ -34,13 +34,31 @@ export const DEFAULT_STAGING_PREFIX = 'staging.'
34
34
  export const BRANCH_HEADER = 'x-vibe-branch'
35
35
  export const BRANCH_COOKIE = 'vibe_branch'
36
36
 
37
- // Pure: pull the selected branch out of a request's headers (header first, then
38
- // the `vibe_branch` cookie). Returns '' when none is present so the resolver can
39
- // fall back to the site's configured default branch.
37
+ // Pure: pull the selected branch out of a request. Precedence (high low):
38
+ // 1. x-vibe-branch header (set by fetch/XHR after the picker)
39
+ // 2. ?branch=<name> URL search param (deliberate URL navigation, HAP-1175)
40
+ // 3. vibe_branch cookie (set by the picker after selection)
41
+ // Returns '' when none is present so the resolver can fall back to the site's
42
+ // configured default branch.
40
43
  export function parseBranchFromReq(req) {
41
44
  const headerVal = req?.headers?.[BRANCH_HEADER]
42
45
  if (headerVal) return String(Array.isArray(headerVal) ? headerVal[0] : headerVal).trim()
43
46
 
47
+ const url = req?.url || ''
48
+ const q = url.indexOf('?')
49
+ if (q !== -1) {
50
+ for (const pair of url.slice(q + 1).split('&')) {
51
+ const eq = pair.indexOf('=')
52
+ const key = eq === -1 ? pair : pair.slice(0, eq)
53
+ if (key === 'branch') {
54
+ const val = eq === -1 ? '' : pair.slice(eq + 1)
55
+ if (val) {
56
+ try { return decodeURIComponent(val.trim()) } catch { return val.trim() }
57
+ }
58
+ }
59
+ }
60
+ }
61
+
44
62
  const cookieHeader = req?.headers?.cookie || ''
45
63
  for (const pair of String(cookieHeader).split(';')) {
46
64
  const idx = pair.indexOf('=')
@@ -56,6 +74,52 @@ export function parseBranchFromReq(req) {
56
74
  return ''
57
75
  }
58
76
 
77
+ // Pure: does this request carry a DELIBERATE branch signal (HAP-1175)?
78
+ //
79
+ // The gateway uses this — not "is there any Cookie/Authorization header" — to
80
+ // decide whether to attempt per-branch resolution. Random session cookies left
81
+ // over from prior visits do NOT count; only signals the app sets on purpose:
82
+ //
83
+ // 1. the `x-vibe-branch` request header (set by fetch/XHR after the picker)
84
+ // 2. a non-empty `?branch=` URL search param (deliberate URL navigation)
85
+ // 3. a non-empty `vibe_branch` cookie (set by the picker after selection)
86
+ //
87
+ // Returns true iff at least one of those carries a non-empty value. A request
88
+ // with only unrelated cookies (e.g. `foo=bar`, `connect.sid=...`) returns false
89
+ // so the gateway defaults it to the shared fallback front, not the resolver.
90
+ export function hasBranchSignal(req) {
91
+ const headerVal = req?.headers?.[BRANCH_HEADER]
92
+ if (headerVal && String(Array.isArray(headerVal) ? headerVal[0] : headerVal).trim()) {
93
+ return true
94
+ }
95
+
96
+ const url = req?.url || ''
97
+ const q = url.indexOf('?')
98
+ if (q !== -1) {
99
+ for (const pair of url.slice(q + 1).split('&')) {
100
+ const eq = pair.indexOf('=')
101
+ const key = eq === -1 ? pair : pair.slice(0, eq)
102
+ if (key === 'branch') {
103
+ const val = eq === -1 ? '' : pair.slice(eq + 1)
104
+ if (val) return true
105
+ }
106
+ }
107
+ }
108
+
109
+ const cookieHeader = req?.headers?.cookie || ''
110
+ if (cookieHeader) {
111
+ for (const pair of String(cookieHeader).split(';')) {
112
+ const idx = pair.indexOf('=')
113
+ if (idx === -1) continue
114
+ if (pair.slice(0, idx).trim() === BRANCH_COOKIE) {
115
+ if (pair.slice(idx + 1).trim()) return true
116
+ }
117
+ }
118
+ }
119
+
120
+ return false
121
+ }
122
+
59
123
  // Pure: does this request's Host header look like a staging request, and what
60
124
  // site slug does it resolve to?
61
125
  //
@@ -282,9 +346,22 @@ export function createHttpResolver({
282
346
  * - Matching staging host → resolve via Mango → http-proxy to the workspace port.
283
347
  * - WebSocket upgrade (Vite HMR) → same resolution → proxy.ws to the same port.
284
348
  * - Non-staging host → 404 with a clear message (no misroute).
285
- * - Resolver errors (401/403/404/502) → surfaced as the resolver's HTTP status.
349
+ * - Resolver errors (403/404/502) → surfaced as the resolver's HTTP status.
286
350
  * - Upgrade-time resolver failure → socket destroyed (Vite retries the WS).
287
351
  *
352
+ * Page-load auth (HAP-1175, supersedes HAP-1173): a top-level browser navigation
353
+ * MUST never 401, regardless of what cookies it carries. Real browsers attach
354
+ * stale session cookies from prior visits on every request, so a "has Cookie →
355
+ * try the resolver" gate locks out every returning user (the original HAP-1173
356
+ * fix only solved the cookie-less case). The new contract: the gateway defaults
357
+ * ALL requests to `fallbackPort` (the shared front) and only resolves a per-
358
+ * branch workspace when the request carries a DELIBERATE branch signal — the
359
+ * `vibe_branch` cookie the picker sets after selection, the `x-vibe-branch`
360
+ * header on fetch/XHR, or a `?branch=` URL search param. If a signaled resolve
361
+ * fails (no auth, no workspace, resolver error), the request STILL falls back
362
+ * to the shared front rather than 401-ing. Hard errors only surface in
363
+ * "lockdown" mode where no `fallbackPort` is configured.
364
+ *
288
365
  * Returns { server, proxy, close() } — caller owns server.listen(port, host).
289
366
  */
290
367
  export function createStagingGatewayServer({
@@ -295,6 +372,7 @@ export function createStagingGatewayServer({
295
372
  backendPort,
296
373
  resolverPath,
297
374
  timeoutMs,
375
+ fallbackPort,
298
376
  onError,
299
377
  } = {}) {
300
378
  if (!http || typeof http.createServer !== 'function') {
@@ -306,9 +384,13 @@ export function createStagingGatewayServer({
306
384
  if (!Number.isInteger(backendPort)) {
307
385
  throw new Error('createStagingGatewayServer: backendPort (integer) is required')
308
386
  }
387
+ if (fallbackPort != null && (!Number.isInteger(fallbackPort) || fallbackPort <= 0 || fallbackPort > 65535)) {
388
+ throw new Error('createStagingGatewayServer: fallbackPort, if set, must be an integer in 1..65535')
389
+ }
309
390
 
310
391
  const resolve = createHttpResolver({ backendPort, resolverPath, timeoutMs, http })
311
392
  const proxy = httpProxy.createProxyServer({ ws: true, xfwd: true })
393
+ const fallbackTarget = Number.isInteger(fallbackPort) ? `http://127.0.0.1:${fallbackPort}` : null
312
394
 
313
395
  const log = (where, err, req) => {
314
396
  if (typeof onError === 'function') onError(where, err, req)
@@ -342,8 +424,28 @@ export function createStagingGatewayServer({
342
424
  sendError(res, 404, `Unknown host: ${req?.headers?.host || '(missing Host header)'}`)
343
425
  return
344
426
  }
427
+ // HAP-1175: default route is the shared fallback front. Only requests
428
+ // that carry a deliberate branch signal (vibe_branch cookie, x-vibe-branch
429
+ // header, or ?branch= URL param) attempt per-branch resolution. A returning
430
+ // browser with stale session cookies but no branch signal — the case that
431
+ // re-broke staging in HAP-1175 — passes straight through here.
432
+ if (!hasBranchSignal(req)) {
433
+ if (fallbackTarget) {
434
+ proxy.web(req, res, { target: fallbackTarget })
435
+ return
436
+ }
437
+ sendError(res, 401, 'Vibe staging: no branch signal and no fallback configured')
438
+ return
439
+ }
345
440
  const outcome = await resolveStagingTarget({ req, slug: parsed.slug, resolve })
346
441
  if (outcome.error) {
442
+ // HAP-1175: even with a branch signal, a failed resolve falls back
443
+ // to the shared front rather than 401-ing the page load. Hard errors
444
+ // surface only in lockdown mode (no fallback configured).
445
+ if (fallbackTarget) {
446
+ proxy.web(req, res, { target: fallbackTarget })
447
+ return
448
+ }
347
449
  sendError(res, outcome.status || 502, outcome.message || outcome.error)
348
450
  return
349
451
  }
@@ -356,9 +458,26 @@ export function createStagingGatewayServer({
356
458
  try { socket.destroy() } catch { /* noop */ }
357
459
  return
358
460
  }
461
+ // HAP-1175: same default-to-fallback rule as handleRequest. The Vite
462
+ // HMR upgrade for the shared shell rides without a branch signal; only
463
+ // after the picker fires does the next upgrade carry x-vibe-branch and
464
+ // resolve per-branch.
465
+ if (!hasBranchSignal(req)) {
466
+ if (fallbackTarget) {
467
+ proxy.ws(req, socket, head, { target: fallbackTarget })
468
+ return
469
+ }
470
+ try { socket.destroy() } catch { /* noop */ }
471
+ return
472
+ }
359
473
  const outcome = await resolveStagingTarget({ req, slug: parsed.slug, resolve })
360
474
  if (outcome.error) {
361
- // Don't write an HTTP response onto an upgrade socket Vite retries.
475
+ // Failed resolve on an upgrade: prefer the fallback over destroying
476
+ // the socket so HMR keeps working through the shared front.
477
+ if (fallbackTarget) {
478
+ proxy.ws(req, socket, head, { target: fallbackTarget })
479
+ return
480
+ }
362
481
  try { socket.destroy() } catch { /* noop */ }
363
482
  return
364
483
  }
@@ -18,6 +18,13 @@
18
18
  * STAGING_HOST_PREFIX (default "staging.") Prefix fallback for slug demux.
19
19
  * RESOLVER_PATH (default /system/vibe/staging-resolve)
20
20
  * RESOLVER_TIMEOUT_MS (default 2000, int)
21
+ * FALLBACK_PORT (default 7121, int) Where to send any request without a
22
+ * deliberate branch signal (vibe_branch
23
+ * cookie, x-vibe-branch header, or
24
+ * ?branch= URL param), or any signaled
25
+ * request whose resolution fails. The
26
+ * shared front (HAP-1175). Set to 0 to
27
+ * disable and 401 those requests instead.
21
28
  *
22
29
  * Usage (typical droplet pm2):
23
30
  * BACKEND_PORT=7122 LISTEN_PORT=7123 \
@@ -40,6 +47,19 @@ function intEnv(name, fallback) {
40
47
  return n
41
48
  }
42
49
 
50
+ function fallbackPortEnv() {
51
+ // Allow `FALLBACK_PORT=0` to opt out (page-loads → 401, the pre-1173 behavior).
52
+ const raw = process.env.FALLBACK_PORT
53
+ if (raw === undefined) return 7121
54
+ if (raw === '' || raw === '0') return null
55
+ const n = parseInt(raw, 10)
56
+ if (!Number.isInteger(n) || n <= 0 || n > 65535) {
57
+ console.error(`[staging-gateway] FALLBACK_PORT must be a port in 1..65535 or 0/empty (got "${raw}")`)
58
+ process.exit(1)
59
+ }
60
+ return n
61
+ }
62
+
43
63
  const BACKEND_PORT = intEnv('BACKEND_PORT', null)
44
64
  if (BACKEND_PORT === null) {
45
65
  console.error('[staging-gateway] BACKEND_PORT (int) is required')
@@ -52,6 +72,7 @@ const STAGING_DOMAIN = process.env.STAGING_DOMAIN || ''
52
72
  const STAGING_HOST_PREFIX = process.env.STAGING_HOST_PREFIX || undefined
53
73
  const RESOLVER_PATH = process.env.RESOLVER_PATH || undefined
54
74
  const RESOLVER_TIMEOUT_MS = intEnv('RESOLVER_TIMEOUT_MS', 2000)
75
+ const FALLBACK_PORT = fallbackPortEnv()
55
76
 
56
77
  const { server, close } = createStagingGatewayServer({
57
78
  http,
@@ -61,6 +82,7 @@ const { server, close } = createStagingGatewayServer({
61
82
  backendPort: BACKEND_PORT,
62
83
  resolverPath: RESOLVER_PATH,
63
84
  timeoutMs: RESOLVER_TIMEOUT_MS,
85
+ fallbackPort: FALLBACK_PORT,
64
86
  onError: (where, err) => {
65
87
  console.error(`[staging-gateway] ${where} error: ${err?.message || err}`)
66
88
  },
@@ -70,6 +92,11 @@ server.listen(LISTEN_PORT, LISTEN_HOST, () => {
70
92
  const target = STAGING_DOMAIN || `${STAGING_HOST_PREFIX || 'staging.'}*`
71
93
  console.log(`[staging-gateway] listening on ${LISTEN_HOST}:${LISTEN_PORT}`)
72
94
  console.log(`[staging-gateway] ${target} -> per-branch Vibe workspace (resolved via 127.0.0.1:${BACKEND_PORT})`)
95
+ if (FALLBACK_PORT) {
96
+ console.log(`[staging-gateway] no branch signal / failed resolve -> 127.0.0.1:${FALLBACK_PORT}`)
97
+ } else {
98
+ console.log('[staging-gateway] fallback disabled (no branch signal → 401, failed resolve → resolver status)')
99
+ }
73
100
  })
74
101
 
75
102
  function shutdown(signal) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mango-cms",
3
- "version": "0.3.27",
3
+ "version": "0.3.29",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "exports": {