mango-cms 0.3.25 → 0.3.27

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/lib/dev-proxy.js CHANGED
@@ -50,7 +50,8 @@ proxy.on('error', (err, req, res) => {
50
50
  });
51
51
 
52
52
  // Production resolver for the staging route: hits a small Mango endpoint that
53
- // resolves (cookie/auth → memberId) and looks up vibeWorkspaces by (userId, slug).
53
+ // authorizes the member (cookie/auth) and looks up vibeWorkspaces by the selected
54
+ // (site, branch) — the branch travels with the request (HAP-1164).
54
55
  const stagingResolver = createHttpResolver({ backendPort, http });
55
56
 
56
57
  function getTarget(host) {
@@ -17,14 +17,45 @@
17
17
  * Cookie / Authorization headers to Mango on the backend port — Mango identifies
18
18
  * the member (`req.session.memberId` for the session cookie, member by
19
19
  * password-hash for the Authorization header — same path getMember() uses
20
- * everywhere else), looks up `vibeWorkspaces` by (userId, slug), and returns
21
- * the workspace's port. The endpoint that does the lookup is owned by the
22
- * provisioning service (HAP-1154); the contract is documented on
23
- * createHttpResolver below.
20
+ * everywhere else) and authorizes them, but the workspace is keyed by the
21
+ * SELECTED BRANCH (HAP-1164), looked up as `vibeWorkspaces` by (site, branch).
22
+ * The branch travels with the request (x-vibe-branch header / vibe_branch cookie);
23
+ * two members on the same branch resolve to the same port. The endpoint that does
24
+ * the lookup is owned by the provisioning service (HAP-1154); the contract is
25
+ * documented on createHttpResolver below.
24
26
  */
25
27
 
26
28
  export const DEFAULT_STAGING_PREFIX = 'staging.'
27
29
 
30
+ // Header / cookie the editing surface (and the orchestrator) use to carry the
31
+ // SELECTED BRANCH with a staging request (HAP-1164). The branch — not the session
32
+ // identity — is what the resolver demuxes on, so it must travel with every
33
+ // request. Header wins over cookie when both are present.
34
+ export const BRANCH_HEADER = 'x-vibe-branch'
35
+ export const BRANCH_COOKIE = 'vibe_branch'
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.
40
+ export function parseBranchFromReq(req) {
41
+ const headerVal = req?.headers?.[BRANCH_HEADER]
42
+ if (headerVal) return String(Array.isArray(headerVal) ? headerVal[0] : headerVal).trim()
43
+
44
+ const cookieHeader = req?.headers?.cookie || ''
45
+ for (const pair of String(cookieHeader).split(';')) {
46
+ const idx = pair.indexOf('=')
47
+ if (idx === -1) continue
48
+ if (pair.slice(0, idx).trim() === BRANCH_COOKIE) {
49
+ try {
50
+ return decodeURIComponent(pair.slice(idx + 1).trim())
51
+ } catch {
52
+ return pair.slice(idx + 1).trim()
53
+ }
54
+ }
55
+ }
56
+ return ''
57
+ }
58
+
28
59
  // Pure: does this request's Host header look like a staging request, and what
29
60
  // site slug does it resolve to?
30
61
  //
@@ -89,9 +120,13 @@ export async function resolveStagingTarget({ req, slug, resolve }) {
89
120
  }
90
121
  }
91
122
 
123
+ // The SELECTED BRANCH (HAP-1164) travels with the request and is what the
124
+ // resolver demuxes on. Absent → resolver falls back to the configured default.
125
+ const branch = parseBranchFromReq(req)
126
+
92
127
  let result
93
128
  try {
94
- result = await resolve({ slug, cookieHeader, authHeader, req })
129
+ result = await resolve({ slug, branch, cookieHeader, authHeader, req })
95
130
  } catch (err) {
96
131
  return { error: 'resolver_error', status: 502, message: `Vibe staging resolver error: ${err?.message || err}` }
97
132
  }
@@ -117,13 +152,13 @@ export async function resolveStagingTarget({ req, slug, resolve }) {
117
152
  * Production resolver: calls Mango (running on the backend port colocated with
118
153
  * this proxy) at a small system endpoint that does identity + workspace lookup.
119
154
  *
120
- * GET <resolverPath>?slug=<slug>
155
+ * GET <resolverPath>?slug=<slug>[&branch=<branch>]
121
156
  * forwards: Cookie, Authorization
122
157
  *
123
158
  * 200 { port: <int> } workspace found, proxy to that port
124
159
  * 401 no member could be identified
125
160
  * 403 member identified, but not authorized for slug
126
- * 404 no vibeWorkspaces record for (userId, slug)
161
+ * 404 no vibeWorkspaces record for (site, branch)
127
162
  * 5xx / network error surfaced as resolver_error
128
163
  *
129
164
  * The endpoint implementation lives with the provisioning service (HAP-1154)
@@ -146,9 +181,10 @@ export function createHttpResolver({
146
181
  throw new Error('createHttpResolver: http module with request() is required')
147
182
  }
148
183
 
149
- return function resolve({ slug, cookieHeader, authHeader }) {
184
+ return function resolve({ slug, branch, cookieHeader, authHeader }) {
150
185
  return new Promise((resolvePromise) => {
151
- const path = `${resolverPath}?slug=${encodeURIComponent(slug)}`
186
+ let path = `${resolverPath}?slug=${encodeURIComponent(slug)}`
187
+ if (branch) path += `&branch=${encodeURIComponent(branch)}`
152
188
  const headers = {}
153
189
  if (cookieHeader) headers.cookie = cookieHeader
154
190
  if (authHeader) headers.authorization = authHeader
@@ -229,3 +265,128 @@ export function createHttpResolver({
229
265
  })
230
266
  }
231
267
  }
268
+
269
+ /**
270
+ * Standalone staging-gateway server factory (HAP-1165).
271
+ *
272
+ * Wraps parseStagingHost + resolveStagingTarget + createHttpResolver into a
273
+ * runnable HTTP server fronted by nginx on the droplet. Listens on a configured
274
+ * loopback port; nginx proxy_passes `staging-vibe.<site>` to it instead of to
275
+ * the shared `:7121` front, so per-branch isolation actually happens.
276
+ *
277
+ * Pure DI: `http` and `httpProxy` are injected so the module itself has no
278
+ * runtime imports and stays unit-testable with plain node fakes. The thin
279
+ * CLI entry `lib/staging-gateway.js` wires the real modules.
280
+ *
281
+ * Behavior:
282
+ * - Matching staging host → resolve via Mango → http-proxy to the workspace port.
283
+ * - WebSocket upgrade (Vite HMR) → same resolution → proxy.ws to the same port.
284
+ * - Non-staging host → 404 with a clear message (no misroute).
285
+ * - Resolver errors (401/403/404/502) → surfaced as the resolver's HTTP status.
286
+ * - Upgrade-time resolver failure → socket destroyed (Vite retries the WS).
287
+ *
288
+ * Returns { server, proxy, close() } — caller owns server.listen(port, host).
289
+ */
290
+ export function createStagingGatewayServer({
291
+ http,
292
+ httpProxy,
293
+ stagingDomain,
294
+ stagingHostPrefix,
295
+ backendPort,
296
+ resolverPath,
297
+ timeoutMs,
298
+ onError,
299
+ } = {}) {
300
+ if (!http || typeof http.createServer !== 'function') {
301
+ throw new Error('createStagingGatewayServer: http module with createServer() is required')
302
+ }
303
+ if (!httpProxy || typeof httpProxy.createProxyServer !== 'function') {
304
+ throw new Error('createStagingGatewayServer: httpProxy module with createProxyServer() is required')
305
+ }
306
+ if (!Number.isInteger(backendPort)) {
307
+ throw new Error('createStagingGatewayServer: backendPort (integer) is required')
308
+ }
309
+
310
+ const resolve = createHttpResolver({ backendPort, resolverPath, timeoutMs, http })
311
+ const proxy = httpProxy.createProxyServer({ ws: true, xfwd: true })
312
+
313
+ const log = (where, err, req) => {
314
+ if (typeof onError === 'function') onError(where, err, req)
315
+ }
316
+
317
+ proxy.on('error', (err, req, res) => {
318
+ log('proxy', err, req)
319
+ if (res && typeof res.writeHead === 'function' && !res.headersSent) {
320
+ res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' })
321
+ res.end(`Bad gateway: ${err?.message || err}`)
322
+ } else if (res && typeof res.destroy === 'function') {
323
+ // Upgrade path: `res` is a Socket — close it, browser retries.
324
+ try { res.destroy() } catch { /* noop */ }
325
+ }
326
+ })
327
+
328
+ const parseHostOpts = { stagingDomain, stagingHostPrefix }
329
+ function parseHost(req) {
330
+ return parseStagingHost(req?.headers?.host, parseHostOpts)
331
+ }
332
+
333
+ function sendError(res, status, message) {
334
+ if (!res || res.headersSent) return
335
+ res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' })
336
+ res.end(message)
337
+ }
338
+
339
+ async function handleRequest(req, res) {
340
+ const parsed = parseHost(req)
341
+ if (!parsed) {
342
+ sendError(res, 404, `Unknown host: ${req?.headers?.host || '(missing Host header)'}`)
343
+ return
344
+ }
345
+ const outcome = await resolveStagingTarget({ req, slug: parsed.slug, resolve })
346
+ if (outcome.error) {
347
+ sendError(res, outcome.status || 502, outcome.message || outcome.error)
348
+ return
349
+ }
350
+ proxy.web(req, res, { target: outcome.target })
351
+ }
352
+
353
+ async function handleUpgrade(req, socket, head) {
354
+ const parsed = parseHost(req)
355
+ if (!parsed) {
356
+ try { socket.destroy() } catch { /* noop */ }
357
+ return
358
+ }
359
+ const outcome = await resolveStagingTarget({ req, slug: parsed.slug, resolve })
360
+ if (outcome.error) {
361
+ // Don't write an HTTP response onto an upgrade socket — Vite retries.
362
+ try { socket.destroy() } catch { /* noop */ }
363
+ return
364
+ }
365
+ proxy.ws(req, socket, head, { target: outcome.target })
366
+ }
367
+
368
+ const server = http.createServer((req, res) => {
369
+ handleRequest(req, res).catch((err) => {
370
+ log('request', err, req)
371
+ sendError(res, 502, `Staging gateway error: ${err?.message || err}`)
372
+ })
373
+ })
374
+
375
+ server.on('upgrade', (req, socket, head) => {
376
+ handleUpgrade(req, socket, head).catch((err) => {
377
+ log('upgrade', err, req)
378
+ try { socket.destroy() } catch { /* noop */ }
379
+ })
380
+ })
381
+
382
+ return {
383
+ server,
384
+ proxy,
385
+ close() {
386
+ return new Promise((res) => {
387
+ try { proxy.close(() => {}) } catch { /* noop */ }
388
+ server.close(() => res())
389
+ })
390
+ },
391
+ }
392
+ }
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Mango Vibe Staging Gateway (HAP-1165)
5
+ *
6
+ * Standalone server that fronts the multi-user staging editing surface. nginx
7
+ * proxy_passes the staging hostname (e.g. `staging-vibe.generations.org`) to
8
+ * this process on a loopback port; the gateway demuxes by selected branch
9
+ * (HAP-1164) into the right Vibe workspace's dev-server port, including the
10
+ * Vite HMR WebSocket upgrade.
11
+ *
12
+ * Configuration (env vars):
13
+ * BACKEND_PORT (required, int) Mango port that owns /system/vibe/staging-resolve.
14
+ * LISTEN_PORT (default 7123, int) Loopback port to bind.
15
+ * LISTEN_HOST (default 127.0.0.1) Bind host — keep loopback so nginx fronts it.
16
+ * STAGING_DOMAIN (optional) Exact staging hostname; when set, only that host
17
+ * (and `staging.*` prefix matches) are routed.
18
+ * STAGING_HOST_PREFIX (default "staging.") Prefix fallback for slug demux.
19
+ * RESOLVER_PATH (default /system/vibe/staging-resolve)
20
+ * RESOLVER_TIMEOUT_MS (default 2000, int)
21
+ *
22
+ * Usage (typical droplet pm2):
23
+ * BACKEND_PORT=7122 LISTEN_PORT=7123 \
24
+ * STAGING_DOMAIN=staging-vibe.generations.org \
25
+ * node node_modules/mango-cms/lib/staging-gateway.js
26
+ */
27
+
28
+ import http from 'http'
29
+ import httpProxy from 'http-proxy'
30
+ import { createStagingGatewayServer } from './devProxyGateway.js'
31
+
32
+ function intEnv(name, fallback) {
33
+ const raw = process.env[name]
34
+ if (raw === undefined || raw === '') return fallback
35
+ const n = parseInt(raw, 10)
36
+ if (!Number.isInteger(n) || n <= 0) {
37
+ console.error(`[staging-gateway] ${name} must be a positive integer (got "${raw}")`)
38
+ process.exit(1)
39
+ }
40
+ return n
41
+ }
42
+
43
+ const BACKEND_PORT = intEnv('BACKEND_PORT', null)
44
+ if (BACKEND_PORT === null) {
45
+ console.error('[staging-gateway] BACKEND_PORT (int) is required')
46
+ process.exit(1)
47
+ }
48
+
49
+ const LISTEN_PORT = intEnv('LISTEN_PORT', 7123)
50
+ const LISTEN_HOST = process.env.LISTEN_HOST || '127.0.0.1'
51
+ const STAGING_DOMAIN = process.env.STAGING_DOMAIN || ''
52
+ const STAGING_HOST_PREFIX = process.env.STAGING_HOST_PREFIX || undefined
53
+ const RESOLVER_PATH = process.env.RESOLVER_PATH || undefined
54
+ const RESOLVER_TIMEOUT_MS = intEnv('RESOLVER_TIMEOUT_MS', 2000)
55
+
56
+ const { server, close } = createStagingGatewayServer({
57
+ http,
58
+ httpProxy,
59
+ stagingDomain: STAGING_DOMAIN || undefined,
60
+ stagingHostPrefix: STAGING_HOST_PREFIX,
61
+ backendPort: BACKEND_PORT,
62
+ resolverPath: RESOLVER_PATH,
63
+ timeoutMs: RESOLVER_TIMEOUT_MS,
64
+ onError: (where, err) => {
65
+ console.error(`[staging-gateway] ${where} error: ${err?.message || err}`)
66
+ },
67
+ })
68
+
69
+ server.listen(LISTEN_PORT, LISTEN_HOST, () => {
70
+ const target = STAGING_DOMAIN || `${STAGING_HOST_PREFIX || 'staging.'}*`
71
+ console.log(`[staging-gateway] listening on ${LISTEN_HOST}:${LISTEN_PORT}`)
72
+ console.log(`[staging-gateway] ${target} -> per-branch Vibe workspace (resolved via 127.0.0.1:${BACKEND_PORT})`)
73
+ })
74
+
75
+ function shutdown(signal) {
76
+ console.log(`[staging-gateway] ${signal} — closing server`)
77
+ close().then(() => process.exit(0)).catch(() => process.exit(1))
78
+ setTimeout(() => process.exit(1), 5000).unref()
79
+ }
80
+ process.on('SIGTERM', () => shutdown('SIGTERM'))
81
+ process.on('SIGINT', () => shutdown('SIGINT'))
package/package.json CHANGED
@@ -1,107 +1,107 @@
1
1
  {
2
- "name": "mango-cms",
3
- "version": "0.3.25",
4
- "type": "module",
5
- "main": "./index.js",
6
- "exports": {
7
- ".": "./index.js",
8
- "./package.json": "./package.json"
9
- },
10
- "files": [
11
- "index.js",
12
- "cli.js",
13
- "vite.config.js",
14
- "lib/**/*",
15
- "default/**/*",
16
- "!default/node_modules/**"
17
- ],
18
- "author": "Colton Neifert",
19
- "license": "Commercial",
20
- "bin": {
21
- "mango": "cli.js"
22
- },
23
- "scripts": {
24
- "dev": "node cli.js dev",
25
- "start": "node cli.js start",
26
- "build": "node cli.js build"
27
- },
28
- "peerDependencies": {
29
- "algoliasearch": "^4.10.3",
30
- "axios": "^0.21.4",
31
- "vue": "^3.0.0",
32
- "vue-router": "^4.0.0"
33
- },
34
- "devDependencies": {
35
- "@vitejs/plugin-vue": "^4.0.0",
36
- "vite": "^4.0.0"
37
- },
38
- "dependencies": {
39
- "@aws-sdk/client-s3": "^3.423.0",
40
- "@aws-sdk/lib-storage": "^3.423.0",
41
- "@sentry/node": "^7.85.0",
42
- "@sentry/profiling-node": "^1.2.6",
43
- "adm-zip": "^0.5.16",
44
- "algoliasearch": "^4.10.3",
45
- "apollo-server": "^2.19.1",
46
- "apollo-server-express": "^2.19.1",
47
- "archiver": "^7.0.1",
48
- "aws-sdk": "^2.1469.0",
49
- "axios": "^0.21.4",
50
- "cli-progress": "^3.12.0",
51
- "connect-multiparty": "^2.2.0",
52
- "connect-redis": "7",
53
- "cors": "^2.8.5",
54
- "country-state-city": "^3.2.1",
55
- "crypto": "^1.0.1",
56
- "dayjs": "^1.11.6",
57
- "dotenv": "^8.2.0",
58
- "express": "^4.17.1",
59
- "express-session": "^1.19.0",
60
- "fast-csv": "^5.0.5",
61
- "fs-extra": "^11.3.0",
62
- "get-audio-duration": "^3.1.0",
63
- "googleapis": "^109.0.1",
64
- "graphql": "^15.4.0",
65
- "graphql-fields": "^2.0.3",
66
- "graphql-parse-resolve-info": "^4.12.0",
67
- "graphql-subscriptions": "^1.1.0",
68
- "graphql-type-datetime": "^0.2.4",
69
- "graphql-ws": "^5.9.0",
70
- "html-to-text": "^8.0.0",
71
- "http-proxy": "^1.18.1",
72
- "inquirer": "^8.2.4",
73
- "json-fn": "^1.1.1",
74
- "lodash": "^4.17.21",
75
- "mailgun-js": "^0.22.0",
76
- "mailgun.js": "^9.3.0",
77
- "md5": "^2.3.0",
78
- "mime-types": "^2.1.35",
79
- "moment": "^2.29.1",
80
- "mongodb": "^6.8.0",
81
- "multer": "^1.4.2",
82
- "node-lame": "^1.3.2",
83
- "node-zip": "^1.1.1",
84
- "nodemon": "^3.0.0",
85
- "openai": "^5.15.0",
86
- "pack": "^2.2.0",
87
- "parse-html-text-content": "^1.1.1",
88
- "redis": "^4.7.0",
89
- "resend": "^6.3.0",
90
- "sharp": "^0.32.1",
91
- "socket.io": "^4.8.0",
92
- "socket.io-redis": "^6.1.1",
93
- "source-map-support": "^0.5.19",
94
- "ssh2-sftp-client": "^12.0.1",
95
- "stream": "^0.0.2",
96
- "subscriptions-transport-ws": "^0.9.18",
97
- "util": "^0.12.3",
98
- "uuid": "3.4.0",
99
- "web": "^0.0.2"
100
- },
101
- "optionalDependencies": {
102
- "bufferutil": "^4.0.8",
103
- "kerberos": "^2.0.0",
104
- "snappy": "^7.2.2",
105
- "utf-8-validate": "^6.0.4"
106
- }
2
+ "name": "mango-cms",
3
+ "version": "0.3.27",
4
+ "type": "module",
5
+ "main": "./index.js",
6
+ "exports": {
7
+ ".": "./index.js",
8
+ "./package.json": "./package.json"
9
+ },
10
+ "files": [
11
+ "index.js",
12
+ "cli.js",
13
+ "vite.config.js",
14
+ "lib/**/*",
15
+ "default/**/*",
16
+ "!default/node_modules/**"
17
+ ],
18
+ "author": "Colton Neifert",
19
+ "license": "Commercial",
20
+ "bin": {
21
+ "mango": "cli.js"
22
+ },
23
+ "scripts": {
24
+ "dev": "node cli.js dev",
25
+ "start": "node cli.js start",
26
+ "build": "node cli.js build"
27
+ },
28
+ "peerDependencies": {
29
+ "algoliasearch": "^4.10.3",
30
+ "axios": "^0.21.4",
31
+ "vue": "^3.0.0",
32
+ "vue-router": "^4.0.0"
33
+ },
34
+ "devDependencies": {
35
+ "@vitejs/plugin-vue": "^4.0.0",
36
+ "vite": "^4.0.0"
37
+ },
38
+ "dependencies": {
39
+ "@aws-sdk/client-s3": "^3.423.0",
40
+ "@aws-sdk/lib-storage": "^3.423.0",
41
+ "@sentry/node": "^7.85.0",
42
+ "@sentry/profiling-node": "^1.2.6",
43
+ "adm-zip": "^0.5.16",
44
+ "algoliasearch": "^4.10.3",
45
+ "apollo-server": "^2.19.1",
46
+ "apollo-server-express": "^2.19.1",
47
+ "archiver": "^7.0.1",
48
+ "aws-sdk": "^2.1469.0",
49
+ "axios": "^0.21.4",
50
+ "cli-progress": "^3.12.0",
51
+ "connect-multiparty": "^2.2.0",
52
+ "connect-redis": "7",
53
+ "cors": "^2.8.5",
54
+ "country-state-city": "^3.2.1",
55
+ "crypto": "^1.0.1",
56
+ "dayjs": "^1.11.6",
57
+ "dotenv": "^8.2.0",
58
+ "express": "^4.17.1",
59
+ "express-session": "^1.19.0",
60
+ "fast-csv": "^5.0.5",
61
+ "fs-extra": "^11.3.0",
62
+ "get-audio-duration": "^3.1.0",
63
+ "googleapis": "^109.0.1",
64
+ "graphql": "^15.4.0",
65
+ "graphql-fields": "^2.0.3",
66
+ "graphql-parse-resolve-info": "^4.12.0",
67
+ "graphql-subscriptions": "^1.1.0",
68
+ "graphql-type-datetime": "^0.2.4",
69
+ "graphql-ws": "^5.9.0",
70
+ "html-to-text": "^8.0.0",
71
+ "http-proxy": "^1.18.1",
72
+ "inquirer": "^8.2.4",
73
+ "json-fn": "^1.1.1",
74
+ "lodash": "^4.17.21",
75
+ "mailgun-js": "^0.22.0",
76
+ "mailgun.js": "^9.3.0",
77
+ "md5": "^2.3.0",
78
+ "mime-types": "^2.1.35",
79
+ "moment": "^2.29.1",
80
+ "mongodb": "^6.8.0",
81
+ "multer": "^1.4.2",
82
+ "node-lame": "^1.3.2",
83
+ "node-zip": "^1.1.1",
84
+ "nodemon": "^3.0.0",
85
+ "openai": "^5.15.0",
86
+ "pack": "^2.2.0",
87
+ "parse-html-text-content": "^1.1.1",
88
+ "redis": "^4.7.0",
89
+ "resend": "^6.3.0",
90
+ "sharp": "^0.32.1",
91
+ "socket.io": "^4.8.0",
92
+ "socket.io-redis": "^6.1.1",
93
+ "source-map-support": "^0.5.19",
94
+ "ssh2-sftp-client": "^12.0.1",
95
+ "stream": "^0.0.2",
96
+ "subscriptions-transport-ws": "^0.9.18",
97
+ "util": "^0.12.3",
98
+ "uuid": "3.4.0",
99
+ "web": "^0.0.2"
100
+ },
101
+ "optionalDependencies": {
102
+ "bufferutil": "^4.0.8",
103
+ "kerberos": "^2.0.0",
104
+ "snappy": "^7.2.2",
105
+ "utf-8-validate": "^6.0.4"
106
+ }
107
107
  }