@pleri/olam-cli 0.1.175 → 0.1.182

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.
Files changed (130) hide show
  1. package/README.md +19 -0
  2. package/bin/olam.cjs +22 -0
  3. package/dist/commands/auth.d.ts.map +1 -1
  4. package/dist/commands/auth.js +67 -19
  5. package/dist/commands/auth.js.map +1 -1
  6. package/dist/commands/config.d.ts.map +1 -1
  7. package/dist/commands/config.js +93 -0
  8. package/dist/commands/config.js.map +1 -1
  9. package/dist/commands/destroy.d.ts +41 -0
  10. package/dist/commands/destroy.d.ts.map +1 -1
  11. package/dist/commands/destroy.js +81 -33
  12. package/dist/commands/destroy.js.map +1 -1
  13. package/dist/commands/dispatch-resolve.d.ts +54 -0
  14. package/dist/commands/dispatch-resolve.d.ts.map +1 -0
  15. package/dist/commands/dispatch-resolve.js +105 -0
  16. package/dist/commands/dispatch-resolve.js.map +1 -0
  17. package/dist/commands/dispatch.d.ts.map +1 -1
  18. package/dist/commands/dispatch.js +40 -9
  19. package/dist/commands/dispatch.js.map +1 -1
  20. package/dist/commands/flywheel/index.d.ts.map +1 -1
  21. package/dist/commands/flywheel/index.js +4 -0
  22. package/dist/commands/flywheel/index.js.map +1 -1
  23. package/dist/commands/flywheel/install-sessionstart-hook.d.ts +64 -0
  24. package/dist/commands/flywheel/install-sessionstart-hook.d.ts.map +1 -0
  25. package/dist/commands/flywheel/install-sessionstart-hook.js +197 -0
  26. package/dist/commands/flywheel/install-sessionstart-hook.js.map +1 -0
  27. package/dist/commands/flywheel/k5-validate.d.ts +31 -0
  28. package/dist/commands/flywheel/k5-validate.d.ts.map +1 -1
  29. package/dist/commands/flywheel/k5-validate.js +80 -19
  30. package/dist/commands/flywheel/k5-validate.js.map +1 -1
  31. package/dist/commands/flywheel/session-start.d.ts +26 -0
  32. package/dist/commands/flywheel/session-start.d.ts.map +1 -0
  33. package/dist/commands/flywheel/session-start.js +119 -0
  34. package/dist/commands/flywheel/session-start.js.map +1 -0
  35. package/dist/commands/host-cp.d.ts +0 -3
  36. package/dist/commands/host-cp.d.ts.map +1 -1
  37. package/dist/commands/host-cp.js +27 -2
  38. package/dist/commands/host-cp.js.map +1 -1
  39. package/dist/commands/kg-classify.d.ts.map +1 -1
  40. package/dist/commands/kg-classify.js +20 -0
  41. package/dist/commands/kg-classify.js.map +1 -1
  42. package/dist/commands/kg-doctor.d.ts +67 -6
  43. package/dist/commands/kg-doctor.d.ts.map +1 -1
  44. package/dist/commands/kg-doctor.js +126 -46
  45. package/dist/commands/kg-doctor.js.map +1 -1
  46. package/dist/commands/list.d.ts +27 -0
  47. package/dist/commands/list.d.ts.map +1 -1
  48. package/dist/commands/list.js +67 -19
  49. package/dist/commands/list.js.map +1 -1
  50. package/dist/commands/memory/status.d.ts +18 -0
  51. package/dist/commands/memory/status.d.ts.map +1 -1
  52. package/dist/commands/memory/status.js +38 -2
  53. package/dist/commands/memory/status.js.map +1 -1
  54. package/dist/commands/memory-service-container.d.ts +44 -0
  55. package/dist/commands/memory-service-container.d.ts.map +1 -1
  56. package/dist/commands/memory-service-container.js +49 -0
  57. package/dist/commands/memory-service-container.js.map +1 -1
  58. package/dist/commands/ps.d.ts +32 -0
  59. package/dist/commands/ps.d.ts.map +1 -1
  60. package/dist/commands/ps.js +34 -0
  61. package/dist/commands/ps.js.map +1 -1
  62. package/dist/commands/runbooks.d.ts +32 -0
  63. package/dist/commands/runbooks.d.ts.map +1 -1
  64. package/dist/commands/runbooks.js +79 -22
  65. package/dist/commands/runbooks.js.map +1 -1
  66. package/dist/commands/skills-source.d.ts.map +1 -1
  67. package/dist/commands/skills-source.js +77 -2
  68. package/dist/commands/skills-source.js.map +1 -1
  69. package/dist/commands/upgrade-history.d.ts +0 -2
  70. package/dist/commands/upgrade-history.d.ts.map +1 -1
  71. package/dist/commands/upgrade-history.js +0 -6
  72. package/dist/commands/upgrade-history.js.map +1 -1
  73. package/dist/commands/upgrade-lock.d.ts +0 -9
  74. package/dist/commands/upgrade-lock.d.ts.map +1 -1
  75. package/dist/commands/upgrade-lock.js +1 -1
  76. package/dist/commands/upgrade-lock.js.map +1 -1
  77. package/dist/commands/world-snapshot.d.ts +13 -0
  78. package/dist/commands/world-snapshot.d.ts.map +1 -1
  79. package/dist/commands/world-snapshot.js +81 -1
  80. package/dist/commands/world-snapshot.js.map +1 -1
  81. package/dist/commands/yolo.d.ts +95 -0
  82. package/dist/commands/yolo.d.ts.map +1 -0
  83. package/dist/commands/yolo.js +377 -0
  84. package/dist/commands/yolo.js.map +1 -0
  85. package/dist/image-digests.json +8 -8
  86. package/dist/index.js +3990 -2445
  87. package/dist/index.js.map +1 -1
  88. package/dist/lib/anthropic-base-url-file.d.ts +37 -0
  89. package/dist/lib/anthropic-base-url-file.d.ts.map +1 -0
  90. package/dist/lib/anthropic-base-url-file.js +46 -0
  91. package/dist/lib/anthropic-base-url-file.js.map +1 -0
  92. package/dist/lib/auth-remote.d.ts +9 -17
  93. package/dist/lib/auth-remote.d.ts.map +1 -1
  94. package/dist/lib/auth-remote.js +25 -20
  95. package/dist/lib/auth-remote.js.map +1 -1
  96. package/dist/lib/cf-access-token.d.ts +32 -0
  97. package/dist/lib/cf-access-token.d.ts.map +1 -0
  98. package/dist/lib/cf-access-token.js +52 -0
  99. package/dist/lib/cf-access-token.js.map +1 -0
  100. package/dist/lib/config.d.ts +17 -3
  101. package/dist/lib/config.d.ts.map +1 -1
  102. package/dist/lib/config.js +28 -4
  103. package/dist/lib/config.js.map +1 -1
  104. package/dist/lib/kubectl-context.d.ts +49 -0
  105. package/dist/lib/kubectl-context.d.ts.map +1 -1
  106. package/dist/lib/kubectl-context.js +64 -2
  107. package/dist/lib/kubectl-context.js.map +1 -1
  108. package/dist/lib/upgrade-kubernetes.d.ts +7 -0
  109. package/dist/lib/upgrade-kubernetes.d.ts.map +1 -1
  110. package/dist/lib/upgrade-kubernetes.js +35 -8
  111. package/dist/lib/upgrade-kubernetes.js.map +1 -1
  112. package/dist/mcp-server.js +1470 -991
  113. package/hermes-bundle/version.json +1 -1
  114. package/host-cp/k8s/manifests/45-pvc.yaml +6 -2
  115. package/host-cp/k8s/manifests/50-deployment.yaml +1 -1
  116. package/host-cp/k8s/manifests/auth-service/50-deployment.yaml +1 -1
  117. package/host-cp/k8s/manifests/kg-service/50-deployment.yaml +1 -1
  118. package/host-cp/k8s/manifests/mcp-auth-service/50-deployment.yaml +1 -1
  119. package/host-cp/k8s/manifests/memory-service/50-deployment.yaml +1 -1
  120. package/host-cp/observability/trace-summary.mjs +267 -0
  121. package/host-cp/src/bootstrap-selective.mjs +58 -0
  122. package/host-cp/src/host-stream.mjs +52 -0
  123. package/host-cp/src/plan-chat-service.mjs +51 -0
  124. package/host-cp/src/redirect.mjs +159 -0
  125. package/host-cp/src/resolver.mjs +121 -0
  126. package/host-cp/src/router.mjs +168 -0
  127. package/host-cp/src/serve-only-config.mjs +85 -0
  128. package/host-cp/src/server.mjs +375 -205
  129. package/host-cp/src/world-services.mjs +136 -0
  130. package/package.json +1 -1
@@ -0,0 +1,159 @@
1
+ // redirect.mjs — Phase B3 (plan-chat-spa-supersedes-control-plane).
2
+ //
3
+ // 301 redirect layer that fronts host-cp's HTTP handler. Maps legacy
4
+ // control-plane routes that get deleted in Phase B4 onto their canonical
5
+ // successors so live URLs in operator history / bookmarks / Slack do not
6
+ // 404 after the deletion lands.
7
+ //
8
+ // Redirect rules (allow-listed; closed set):
9
+ //
10
+ // /plan/:id → no-op (falls through to SPA shell;
11
+ // plan-chat-spa-side router handles the
12
+ // resolver dispatch via useResolveId).
13
+ // Implemented as a sentinel so callers
14
+ // can short-circuit but the request
15
+ // continues to static-serve.
16
+ // /world/:id → 301 /worlds?highlight=:id
17
+ // /sandbox/:id → 301 /worlds?highlight=:id
18
+ // /session/:worldId/plan → 301 /plan/:worldId
19
+ //
20
+ // EXPLICITLY NOT REDIRECTED (more-specific routes still owned by
21
+ // control-plane until Phase E):
22
+ // /world/:id/editor /world/:id/events
23
+ // /sandbox/:id/editor /sandbox/:id/events
24
+ // /inbox/* /workspaces/*
25
+ // /repos /runbooks /design
26
+ //
27
+ // Security (per plan-chat-spa-supersedes-control-plane.md K1 SEC-2):
28
+ // - Redirect targets are HARDCODED prefixes (`/plan/`, `/worlds`). No
29
+ // caller-supplied target is ever reflected into Location.
30
+ // - `:id` segment is validated against RESOLVE_ID_RE before any
31
+ // reflection into the Location header; invalid shapes → 400, not
32
+ // 301. This kills open-redirect / response-splitting / header-
33
+ // injection vectors at the door.
34
+ // - `highlight=<id>` query param uses the SAME shape regex. We do not
35
+ // trust the inbound URL beyond the regex match (no decoding, no
36
+ // surrogate pair handling).
37
+ //
38
+ // Returns one of:
39
+ // { kind: 'redirect', status: 301, location: '<target>' }
40
+ // { kind: 'bad-request', status: 400, message: '<reason>' }
41
+ // { kind: 'passthrough' } — caller continues normal request flow
42
+
43
+ import { RESOLVE_ID_RE } from './resolver.mjs';
44
+
45
+ /**
46
+ * Compute the redirect verdict for a given pathname. Pure function;
47
+ * does not consume the request body or write the response.
48
+ *
49
+ * @param {string} pathname - URL.pathname (no querystring, no hash)
50
+ * @returns {{ kind: 'redirect', status: 301, location: string }
51
+ * | { kind: 'bad-request', status: 400, message: string }
52
+ * | { kind: 'passthrough' }}
53
+ */
54
+ export function evaluateRedirect(pathname) {
55
+ if (typeof pathname !== 'string' || pathname.length === 0) {
56
+ return { kind: 'passthrough' };
57
+ }
58
+
59
+ // /session/:worldId/plan → /plan/:worldId
60
+ // Match BEFORE the catch-all world rules so the `/session/...` prefix
61
+ // wins. The trailing `/plan` is fixed; only the worldId varies.
62
+ const sessionMatch = /^\/session\/([^/]+)\/plan\/?$/.exec(pathname);
63
+ if (sessionMatch) {
64
+ const worldId = sessionMatch[1];
65
+ if (!RESOLVE_ID_RE.test(worldId)) {
66
+ return {
67
+ kind: 'bad-request',
68
+ status: 400,
69
+ message: 'invalid worldId shape on /session/:worldId/plan',
70
+ };
71
+ }
72
+ return {
73
+ kind: 'redirect',
74
+ status: 301,
75
+ location: `/plan/${worldId}`,
76
+ };
77
+ }
78
+
79
+ // /design → / (Phase E2: the DesignSurface alpha placeholder is retired.
80
+ // Hardcoded target — no caller reflection. Exact-match only so /designfoo
81
+ // or /design/sub do not over-match into the redirect.)
82
+ if (pathname === '/design' || pathname === '/design/') {
83
+ return { kind: 'redirect', status: 301, location: '/' };
84
+ }
85
+
86
+ // /world/:id (catch-all, EXCLUDING /editor and /events sub-routes)
87
+ // /sandbox/:id (catch-all, EXCLUDING /editor and /events sub-routes)
88
+ const worldMatch = /^\/(world|sandbox)\/([^/]+)(\/.*)?$/.exec(pathname);
89
+ if (worldMatch) {
90
+ const [, , id, rest] = worldMatch;
91
+ // KEEP these — control-plane still owns them until Phase E.
92
+ if (rest === '/editor' || rest === '/events' ||
93
+ rest?.startsWith('/editor/') || rest?.startsWith('/events/')) {
94
+ return { kind: 'passthrough' };
95
+ }
96
+ if (!RESOLVE_ID_RE.test(id)) {
97
+ return {
98
+ kind: 'bad-request',
99
+ status: 400,
100
+ message: 'invalid id shape on /(world|sandbox)/:id',
101
+ };
102
+ }
103
+ return {
104
+ kind: 'redirect',
105
+ status: 301,
106
+ location: `/worlds?highlight=${encodeURIComponent(id)}`,
107
+ };
108
+ }
109
+
110
+ // /plan/:id is intentionally passthrough — the SPA shell serves it
111
+ // and the SPA-side router (with useResolveId) decides what to mount.
112
+ // We DO NOT emit a self-loop 301 here. Including the rule for
113
+ // completeness / future-proofing only.
114
+ // (No regex needed; the static-serve layer already handles /plan/*
115
+ // via SPA_PREFIX.)
116
+
117
+ return { kind: 'passthrough' };
118
+ }
119
+
120
+ /**
121
+ * Apply the redirect verdict to a node:http ServerResponse. Returns
122
+ * `true` when the response was written (caller must NOT continue);
123
+ * returns `false` when the caller should continue the normal request
124
+ * flow.
125
+ *
126
+ * @param {import('node:http').ServerResponse} res
127
+ * @param {ReturnType<typeof evaluateRedirect>} verdict
128
+ * @returns {boolean} true if response was sent, false to passthrough.
129
+ */
130
+ export function applyRedirect(res, verdict) {
131
+ if (verdict.kind === 'passthrough') return false;
132
+
133
+ if (verdict.kind === 'redirect') {
134
+ res.writeHead(301, {
135
+ Location: verdict.location,
136
+ // Short cache so bookmarks update once but operator-local mistakes
137
+ // (typo'd URL) don't pin to a stale redirect forever.
138
+ 'Cache-Control': 'public, max-age=300',
139
+ 'Content-Type': 'text/plain; charset=utf-8',
140
+ });
141
+ res.end(`Moved permanently: ${verdict.location}\n`);
142
+ return true;
143
+ }
144
+
145
+ if (verdict.kind === 'bad-request') {
146
+ res.writeHead(400, {
147
+ 'Content-Type': 'application/json; charset=utf-8',
148
+ 'Cache-Control': 'no-store',
149
+ });
150
+ res.end(JSON.stringify({
151
+ error: 'bad-request',
152
+ message: verdict.message,
153
+ }));
154
+ return true;
155
+ }
156
+
157
+ // Defensive: unknown verdict shape → fall through silently.
158
+ return false;
159
+ }
@@ -0,0 +1,121 @@
1
+ // resolver.mjs — Phase A A1 (plan-chat-spa-supersedes-control-plane).
2
+ //
3
+ // Disambiguates a single opaque :id supplied on /plan/:id between
4
+ //
5
+ // - a planning session (planning_sessions.session_id), or
6
+ // - a crystallized world (planning_artifacts.crystallized_world_id), or
7
+ // - unresolvable (returns {kind:'unresolved', canonical_id:null}).
8
+ //
9
+ // Used by plan-chat-spa's useResolveId hook (Phase A A2) so the SPA's
10
+ // cold-open path can mount the correct surface without trusting the
11
+ // id-shape (sentinel `sess_*` prefix is a hint, not authority — see
12
+ // plan-chat-spa-supersedes-control-plane.md K1 SEC-1).
13
+ //
14
+ // Single SQL query (UNION ALL) so resolution costs one round-trip even
15
+ // when the id misses both tables. Bearer auth + rate-limit live in the
16
+ // HTTP handler in plan-chat-service.mjs; this helper is pool-pure for
17
+ // unit testability.
18
+
19
+ /**
20
+ * Validate the resolver :id shape. Mirrors plan-chat-service.mjs's
21
+ * SCOPE_ID_RE; tightened to 6-80 chars so an enumeration attacker can't
22
+ * grind through 1-5 char shapes.
23
+ */
24
+ export const RESOLVE_ID_RE = /^[A-Za-z0-9._-]{6,80}$/;
25
+
26
+ /**
27
+ * Resolve an opaque id against the chunks substrate.
28
+ *
29
+ * @param {{ query: (sql: string, params: unknown[]) => Promise<{ rows: unknown[] }> }} pool
30
+ * A pg-shaped pool. Tests pass a stub; production passes pg.Pool.
31
+ * @param {string} id The candidate id.
32
+ * @returns {Promise<{ kind: 'session' | 'world' | 'unresolved', canonical_id: string | null }>}
33
+ */
34
+ export async function resolveId(pool, id) {
35
+ if (typeof id !== 'string' || !RESOLVE_ID_RE.test(id)) {
36
+ return { kind: 'unresolved', canonical_id: null };
37
+ }
38
+
39
+ // Single round-trip. Both branches return the same shape
40
+ // (kind, canonical_id) so PG can UNION ALL them without coercion.
41
+ //
42
+ // Session branch wins on tie (LIMIT 1 + session ordered first) — a
43
+ // session id colliding with a world id is unlikely in practice
44
+ // (worldId is the random docker name; sessionId is uuid-shaped),
45
+ // but the deterministic ordering closes the K1 collision risk
46
+ // surfaced in pass 3 review.
47
+ const sql = `
48
+ SELECT kind, canonical_id FROM (
49
+ SELECT 'session' AS kind, session_id AS canonical_id, 1 AS rank
50
+ FROM planning_sessions
51
+ WHERE session_id = $1
52
+ UNION ALL
53
+ SELECT 'world' AS kind, crystallized_world_id AS canonical_id, 2 AS rank
54
+ FROM planning_artifacts
55
+ WHERE crystallized_world_id = $1
56
+ ) AS resolved
57
+ ORDER BY rank
58
+ LIMIT 1
59
+ `;
60
+
61
+ const result = await pool.query(sql, [id]);
62
+ const row = result.rows && result.rows[0];
63
+ if (!row) return { kind: 'unresolved', canonical_id: null };
64
+
65
+ // Pool stub-friendly: tolerate column names emerging from pg's
66
+ // case-insensitive identifier handling.
67
+ const kind = row.kind ?? row.KIND;
68
+ const canonical_id = row.canonical_id ?? row.CANONICAL_ID;
69
+ if (kind !== 'session' && kind !== 'world') {
70
+ return { kind: 'unresolved', canonical_id: null };
71
+ }
72
+ if (typeof canonical_id !== 'string' || canonical_id.length === 0) {
73
+ return { kind: 'unresolved', canonical_id: null };
74
+ }
75
+ return { kind, canonical_id };
76
+ }
77
+
78
+ /**
79
+ * Token-bucket rate limiter, per bearer principal. Closes the brute-
80
+ * force enumeration vector that bearer auth alone leaves open (an
81
+ * authenticated caller could otherwise grind through ids at
82
+ * line-rate).
83
+ *
84
+ * 60 req/min per bearer. Single-process in-memory map (one host-cp
85
+ * per host); a multi-instance deployment would need a shared store,
86
+ * but plan-chat-service is single-tenant single-host by design.
87
+ */
88
+ export function createRateLimiter({
89
+ capacity = 60,
90
+ windowMs = 60_000,
91
+ now = () => Date.now(),
92
+ } = {}) {
93
+ const buckets = new Map(); // key -> { tokens, lastRefill }
94
+
95
+ function take(key) {
96
+ const t = now();
97
+ let bucket = buckets.get(key);
98
+ if (!bucket) {
99
+ bucket = { tokens: capacity, lastRefill: t };
100
+ buckets.set(key, bucket);
101
+ }
102
+ // Refill proportional to elapsed time.
103
+ const elapsed = t - bucket.lastRefill;
104
+ if (elapsed > 0) {
105
+ const refill = (elapsed / windowMs) * capacity;
106
+ bucket.tokens = Math.min(capacity, bucket.tokens + refill);
107
+ bucket.lastRefill = t;
108
+ }
109
+ if (bucket.tokens < 1) {
110
+ return { allowed: false, retryAfterMs: Math.ceil((1 - bucket.tokens) * (windowMs / capacity)) };
111
+ }
112
+ bucket.tokens -= 1;
113
+ return { allowed: true, retryAfterMs: 0 };
114
+ }
115
+
116
+ function reset() {
117
+ buckets.clear();
118
+ }
119
+
120
+ return { take, reset };
121
+ }
@@ -0,0 +1,168 @@
1
+ // host-cp request router.
2
+ //
3
+ // Replaces the long linear `if (url.pathname === ...)` dispatch chain in
4
+ // server.mjs with an ordered route table. The table is walked in
5
+ // registration order, so route PRECEDENCE is preserved exactly as it was
6
+ // in the original if-ladder: the first matching route wins, later routes
7
+ // are never consulted once a match handles the request.
8
+ //
9
+ // Why a table and not a framework:
10
+ // - host-cp ships with no external HTTP framework (no express/fastify);
11
+ // this matches the existing zero-dep style.
12
+ // - The table is a plain data structure, so it is importable + unit
13
+ // testable WITHOUT booting server.mjs (which spawns docker-events,
14
+ // the auth poller, and the worlds.db reconciler at import time).
15
+ // - A route is now a table entry instead of a `return` buried in a
16
+ // 1700-line ladder. That kills the silent route-shadowing class: a
17
+ // misplaced `return` can no longer swallow a later route, and the
18
+ // full set of routes is enumerable (see `router.routes()`).
19
+ //
20
+ // Behavior-preservation contract (load-bearing — see
21
+ // __tests__/router.test.mjs):
22
+ // 1. Walk order == registration order == original source order.
23
+ // 2. A route MATCHES when its matcher returns a truthy match value AND
24
+ // (no method filter OR the method matches). The matcher receives
25
+ // ({ pathname, method, url }) and returns either a boolean or, for
26
+ // regex routes, the RegExpMatchArray (truthy) so the handler can read
27
+ // capture groups.
28
+ // 3. The FIRST matching route is invoked and dispatch STOPS — identical
29
+ // to `if (cond) { ...; return; }`. The handler owns the response.
30
+ // 4. A route whose path matches but whose METHOD does not is SKIPPED,
31
+ // and the walk continues — identical to the original
32
+ // `if (pathMatch && req.method === 'X')` blocks, where a path hit
33
+ // with the wrong method fell through to the next `if`.
34
+ // 5. If no route matches, dispatch returns `false` so the caller runs
35
+ // its terminal 404 — identical to the original fall-through.
36
+ //
37
+ // The router does NOT add auth, body parsing, or any middleware semantics.
38
+ // Those stay exactly where they were in server.mjs (pre-auth routes, the
39
+ // auth gate, the plan-chat bypass) — the router only models the part of
40
+ // the chain that was a flat sequence of `if` blocks.
41
+
42
+ /**
43
+ * @typedef {object} RouteContext
44
+ * @property {string} pathname url.pathname
45
+ * @property {string} method req.method (already normalized by node to uppercase)
46
+ * @property {URL} url parsed request URL
47
+ */
48
+
49
+ /**
50
+ * A matcher decides whether a route applies to a request, ignoring method.
51
+ * Returning a non-boolean truthy value (e.g. a RegExpMatchArray) is
52
+ * forwarded to the handler as `ctx.match` so regex routes can read groups.
53
+ *
54
+ * @typedef {(ctx: RouteContext) => (boolean | RegExpMatchArray | null | undefined)} RouteMatcher
55
+ */
56
+
57
+ /**
58
+ * A handler receives the node req/res plus the parsed url, the matched
59
+ * value (for regex routes), and is responsible for writing the response.
60
+ * It mirrors the body of an original `if` block. Return value is ignored;
61
+ * matching alone terminates dispatch (preserving the `if ... return`
62
+ * semantics where reaching the block always handled the request).
63
+ *
64
+ * @typedef {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, ctx: RouteContext & { match: any }) => unknown | Promise<unknown>} RouteHandler
65
+ */
66
+
67
+ /**
68
+ * @typedef {object} Route
69
+ * @property {string} name human label for diagnostics / tests
70
+ * @property {string[] | null} methods allowed methods, or null for "any method"
71
+ * @property {RouteMatcher} match
72
+ * @property {RouteHandler} handler
73
+ */
74
+
75
+ /**
76
+ * Create an ordered router. Routes are matched in the order they are
77
+ * registered — register in the SAME order the original if-ladder ran.
78
+ */
79
+ export function createRouter() {
80
+ /** @type {Route[]} */
81
+ const routes = [];
82
+
83
+ /**
84
+ * Register a route. Returns the router for chaining.
85
+ *
86
+ * @param {object} spec
87
+ * @param {string} spec.name
88
+ * @param {string | string[] | null} [spec.method] single method, list, or null/omitted for any
89
+ * @param {string} [spec.path] exact pathname match (mutually exclusive with prefix/match)
90
+ * @param {string} [spec.prefix] pathname.startsWith(prefix) match
91
+ * @param {RegExp} [spec.pattern] pathname.match(pattern) — match value passed to handler
92
+ * @param {RouteMatcher} [spec.match] custom matcher (overrides path/prefix/pattern)
93
+ * @param {RouteHandler} spec.handler
94
+ */
95
+ function register(spec) {
96
+ const { name, method, path, prefix, pattern } = spec;
97
+ const handler = spec.handler;
98
+ if (typeof handler !== 'function') {
99
+ throw new TypeError(`route "${name}" requires a handler function`);
100
+ }
101
+
102
+ /** @type {string[] | null} */
103
+ let methods = null;
104
+ if (Array.isArray(method)) methods = method.slice();
105
+ else if (typeof method === 'string') methods = [method];
106
+ // method omitted or null → any method
107
+
108
+ /** @type {RouteMatcher} */
109
+ let match;
110
+ if (typeof spec.match === 'function') {
111
+ match = spec.match;
112
+ } else if (typeof path === 'string') {
113
+ match = (ctx) => ctx.pathname === path;
114
+ } else if (typeof prefix === 'string') {
115
+ match = (ctx) => ctx.pathname.startsWith(prefix);
116
+ } else if (pattern instanceof RegExp) {
117
+ match = (ctx) => ctx.pathname.match(pattern);
118
+ } else {
119
+ throw new TypeError(
120
+ `route "${name}" requires one of: path, prefix, pattern, or match`,
121
+ );
122
+ }
123
+
124
+ routes.push({ name, methods, match, handler });
125
+ return api;
126
+ }
127
+
128
+ /**
129
+ * Walk the table in registration order. Invokes the first route whose
130
+ * matcher is truthy AND whose method filter admits the request, then
131
+ * stops. A path-match with a non-admitted method is skipped (the walk
132
+ * continues), preserving the original `if (pathMatch && method===X)`
133
+ * fall-through.
134
+ *
135
+ * @param {import('node:http').IncomingMessage} req
136
+ * @param {import('node:http').ServerResponse} res
137
+ * @param {URL} url
138
+ * @returns {Promise<boolean>} true if a route handled the request, false to fall through to 404
139
+ */
140
+ async function dispatch(req, res, url) {
141
+ const ctx = { pathname: url.pathname, method: req.method ?? 'GET', url };
142
+ for (const route of routes) {
143
+ const matched = route.match(ctx);
144
+ if (!matched) continue;
145
+ // Path matched. Now gate on method — a mismatch is a SKIP, not a
146
+ // 405, exactly mirroring the original if-ladder fall-through.
147
+ if (route.methods !== null && !route.methods.includes(ctx.method)) {
148
+ continue;
149
+ }
150
+ await route.handler(req, res, { ...ctx, match: matched });
151
+ return true;
152
+ }
153
+ return false;
154
+ }
155
+
156
+ /**
157
+ * Enumerate registered routes (name + methods + matcher kind) for
158
+ * diagnostics, audits, and tests. Pure read of the table.
159
+ *
160
+ * @returns {Array<{ name: string, methods: string[] | null }>}
161
+ */
162
+ function list() {
163
+ return routes.map((r) => ({ name: r.name, methods: r.methods }));
164
+ }
165
+
166
+ const api = { register, dispatch, list, get size() { return routes.length; } };
167
+ return api;
168
+ }
@@ -0,0 +1,85 @@
1
+ // serve-only-config.mjs — host-cp SERVE-ONLY mode gate (Phase A of
2
+ // host-cp-gke-serve-only-mode).
3
+ //
4
+ // host-cp normally runs as a local operator sidecar coupled to the host's
5
+ // docker daemon + operator-repo + gh-config. On a managed GKE cluster those
6
+ // host-couplings are absent: host-cp only serves plan-chat-spa + the
7
+ // host-native `/api/*` surface; world orchestration runs elsewhere.
8
+ //
9
+ // `OLAM_HOST_CP_SERVE_ONLY=true` switches host-cp into that degraded shape:
10
+ // - no docker transport connect, no world discovery
11
+ // - no PlanOrchestrator docker wiring, no pr-merge-poller docker/repo deps
12
+ // - world-orchestration routes (`/api/world/*`) return a structured 503
13
+ // - version-status degrades to 'unknown' (no operator-repo)
14
+ //
15
+ // The flag defaults OFF — the local docker/k3d FULL mode is byte-for-byte
16
+ // unchanged. This module is a tiny pure seam so the gate decision can be
17
+ // unit-tested WITHOUT booting server.mjs (which connects docker + binds a
18
+ // port at module load and therefore can't be imported in a test).
19
+ //
20
+ // ONE coarse flag — no granular per-subsystem toggles (plan S1 / YAGNI).
21
+
22
+ /**
23
+ * Decide whether host-cp runs in SERVE-ONLY mode.
24
+ *
25
+ * Strict `=== 'true'` parse (mirrors the HOST_CP_MODE env-flag convention
26
+ * in server.mjs): only the literal string `'true'` enables it. Any other
27
+ * value — unset, `'1'`, `'false'`, `''`, `'TRUE'` — keeps FULL mode so the
28
+ * default stays OFF and operators can't half-enable it by accident.
29
+ *
30
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
31
+ * Environment to read `OLAM_HOST_CP_SERVE_ONLY` from. Defaults to
32
+ * `process.env`.
33
+ * @returns {boolean} `true` when serve-only mode is active.
34
+ */
35
+ export function isServeOnly(env = process.env) {
36
+ return env?.OLAM_HOST_CP_SERVE_ONLY === 'true';
37
+ }
38
+
39
+ /**
40
+ * Structured 503 body for world-orchestration routes that are unavailable
41
+ * in serve-only mode. Reuses the host-cp `/api/*` JSON-error shape
42
+ * (`{ error, message }`) so SPA error handling treats it uniformly.
43
+ *
44
+ * @type {{ error: 'orchestration_unavailable', message: string }}
45
+ */
46
+ export const ORCHESTRATION_UNAVAILABLE = Object.freeze({
47
+ error: 'orchestration_unavailable',
48
+ message:
49
+ 'host-cp is in serve-only mode (managed cluster); world orchestration runs elsewhere',
50
+ });
51
+
52
+ /**
53
+ * True when `pathname` (+ `method`) is a world-ORCHESTRATION route that must
54
+ * degrade to a structured 503 in serve-only mode. The surface is wider than
55
+ * the singular `/api/world/` proxy: it also covers the plural `/api/worlds/`
56
+ * per-world mutation/read routes (e.g. `POST /api/worlds/<id>/tunnels` which
57
+ * spawns a real cloudflare tunnel, `DELETE /api/worlds/<id>` which destroys a
58
+ * world), world creation (`POST /api/worlds`), and the CLI `/v1/worlds/`
59
+ * routes. Without this breadth a serve-only host-cp on a shared cluster would
60
+ * execute tunnel/destroy mutations — the opposite of honest degradation.
61
+ * (CP3 finding: the singular-only guard let POST /api/worlds/<id>/tunnels
62
+ * open a live public tunnel in serve-only.)
63
+ *
64
+ * Deliberately NOT orchestration: `GET`/`HEAD /api/worlds` (the bare LIST
65
+ * endpoint) — it returns an empty array in serve-only, which is honest.
66
+ *
67
+ * @param {unknown} pathname URL.pathname (no querystring).
68
+ * @param {string} [method] HTTP method (defaults 'GET').
69
+ * @returns {boolean}
70
+ */
71
+ export function isOrchestrationRoute(pathname, method = 'GET') {
72
+ if (typeof pathname !== 'string') return false;
73
+ // Singular /api/world/<id>/... — the per-world CP proxy + /progress.
74
+ if (pathname.startsWith('/api/world/')) return true;
75
+ // CLI per-world routes (olam status/logs <world>).
76
+ if (pathname.startsWith('/v1/worlds/')) return true;
77
+ // Plural /api/worlds:
78
+ // bare LIST (GET/HEAD /api/worlds) → honest [] in serve-only, NOT blocked.
79
+ // create (POST /api/worlds) + any per-world subpath (/api/worlds/<id>...) → 503.
80
+ if (pathname === '/api/worlds') {
81
+ return method !== 'GET' && method !== 'HEAD';
82
+ }
83
+ if (/^\/api\/worlds\/[^/?#]+/.test(pathname)) return true;
84
+ return false;
85
+ }