@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.
- package/README.md +19 -0
- package/bin/olam.cjs +22 -0
- package/dist/commands/auth.d.ts.map +1 -1
- package/dist/commands/auth.js +67 -19
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +93 -0
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/destroy.d.ts +41 -0
- package/dist/commands/destroy.d.ts.map +1 -1
- package/dist/commands/destroy.js +81 -33
- package/dist/commands/destroy.js.map +1 -1
- package/dist/commands/dispatch-resolve.d.ts +54 -0
- package/dist/commands/dispatch-resolve.d.ts.map +1 -0
- package/dist/commands/dispatch-resolve.js +105 -0
- package/dist/commands/dispatch-resolve.js.map +1 -0
- package/dist/commands/dispatch.d.ts.map +1 -1
- package/dist/commands/dispatch.js +40 -9
- package/dist/commands/dispatch.js.map +1 -1
- package/dist/commands/flywheel/index.d.ts.map +1 -1
- package/dist/commands/flywheel/index.js +4 -0
- package/dist/commands/flywheel/index.js.map +1 -1
- package/dist/commands/flywheel/install-sessionstart-hook.d.ts +64 -0
- package/dist/commands/flywheel/install-sessionstart-hook.d.ts.map +1 -0
- package/dist/commands/flywheel/install-sessionstart-hook.js +197 -0
- package/dist/commands/flywheel/install-sessionstart-hook.js.map +1 -0
- package/dist/commands/flywheel/k5-validate.d.ts +31 -0
- package/dist/commands/flywheel/k5-validate.d.ts.map +1 -1
- package/dist/commands/flywheel/k5-validate.js +80 -19
- package/dist/commands/flywheel/k5-validate.js.map +1 -1
- package/dist/commands/flywheel/session-start.d.ts +26 -0
- package/dist/commands/flywheel/session-start.d.ts.map +1 -0
- package/dist/commands/flywheel/session-start.js +119 -0
- package/dist/commands/flywheel/session-start.js.map +1 -0
- package/dist/commands/host-cp.d.ts +0 -3
- package/dist/commands/host-cp.d.ts.map +1 -1
- package/dist/commands/host-cp.js +27 -2
- package/dist/commands/host-cp.js.map +1 -1
- package/dist/commands/kg-classify.d.ts.map +1 -1
- package/dist/commands/kg-classify.js +20 -0
- package/dist/commands/kg-classify.js.map +1 -1
- package/dist/commands/kg-doctor.d.ts +67 -6
- package/dist/commands/kg-doctor.d.ts.map +1 -1
- package/dist/commands/kg-doctor.js +126 -46
- package/dist/commands/kg-doctor.js.map +1 -1
- package/dist/commands/list.d.ts +27 -0
- package/dist/commands/list.d.ts.map +1 -1
- package/dist/commands/list.js +67 -19
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/memory/status.d.ts +18 -0
- package/dist/commands/memory/status.d.ts.map +1 -1
- package/dist/commands/memory/status.js +38 -2
- package/dist/commands/memory/status.js.map +1 -1
- package/dist/commands/memory-service-container.d.ts +44 -0
- package/dist/commands/memory-service-container.d.ts.map +1 -1
- package/dist/commands/memory-service-container.js +49 -0
- package/dist/commands/memory-service-container.js.map +1 -1
- package/dist/commands/ps.d.ts +32 -0
- package/dist/commands/ps.d.ts.map +1 -1
- package/dist/commands/ps.js +34 -0
- package/dist/commands/ps.js.map +1 -1
- package/dist/commands/runbooks.d.ts +32 -0
- package/dist/commands/runbooks.d.ts.map +1 -1
- package/dist/commands/runbooks.js +79 -22
- package/dist/commands/runbooks.js.map +1 -1
- package/dist/commands/skills-source.d.ts.map +1 -1
- package/dist/commands/skills-source.js +77 -2
- package/dist/commands/skills-source.js.map +1 -1
- package/dist/commands/upgrade-history.d.ts +0 -2
- package/dist/commands/upgrade-history.d.ts.map +1 -1
- package/dist/commands/upgrade-history.js +0 -6
- package/dist/commands/upgrade-history.js.map +1 -1
- package/dist/commands/upgrade-lock.d.ts +0 -9
- package/dist/commands/upgrade-lock.d.ts.map +1 -1
- package/dist/commands/upgrade-lock.js +1 -1
- package/dist/commands/upgrade-lock.js.map +1 -1
- package/dist/commands/world-snapshot.d.ts +13 -0
- package/dist/commands/world-snapshot.d.ts.map +1 -1
- package/dist/commands/world-snapshot.js +81 -1
- package/dist/commands/world-snapshot.js.map +1 -1
- package/dist/commands/yolo.d.ts +95 -0
- package/dist/commands/yolo.d.ts.map +1 -0
- package/dist/commands/yolo.js +377 -0
- package/dist/commands/yolo.js.map +1 -0
- package/dist/image-digests.json +8 -8
- package/dist/index.js +3990 -2445
- package/dist/index.js.map +1 -1
- package/dist/lib/anthropic-base-url-file.d.ts +37 -0
- package/dist/lib/anthropic-base-url-file.d.ts.map +1 -0
- package/dist/lib/anthropic-base-url-file.js +46 -0
- package/dist/lib/anthropic-base-url-file.js.map +1 -0
- package/dist/lib/auth-remote.d.ts +9 -17
- package/dist/lib/auth-remote.d.ts.map +1 -1
- package/dist/lib/auth-remote.js +25 -20
- package/dist/lib/auth-remote.js.map +1 -1
- package/dist/lib/cf-access-token.d.ts +32 -0
- package/dist/lib/cf-access-token.d.ts.map +1 -0
- package/dist/lib/cf-access-token.js +52 -0
- package/dist/lib/cf-access-token.js.map +1 -0
- package/dist/lib/config.d.ts +17 -3
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +28 -4
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/kubectl-context.d.ts +49 -0
- package/dist/lib/kubectl-context.d.ts.map +1 -1
- package/dist/lib/kubectl-context.js +64 -2
- package/dist/lib/kubectl-context.js.map +1 -1
- package/dist/lib/upgrade-kubernetes.d.ts +7 -0
- package/dist/lib/upgrade-kubernetes.d.ts.map +1 -1
- package/dist/lib/upgrade-kubernetes.js +35 -8
- package/dist/lib/upgrade-kubernetes.js.map +1 -1
- package/dist/mcp-server.js +1470 -991
- package/hermes-bundle/version.json +1 -1
- package/host-cp/k8s/manifests/45-pvc.yaml +6 -2
- package/host-cp/k8s/manifests/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/auth-service/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/kg-service/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/mcp-auth-service/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/memory-service/50-deployment.yaml +1 -1
- package/host-cp/observability/trace-summary.mjs +267 -0
- package/host-cp/src/bootstrap-selective.mjs +58 -0
- package/host-cp/src/host-stream.mjs +52 -0
- package/host-cp/src/plan-chat-service.mjs +51 -0
- package/host-cp/src/redirect.mjs +159 -0
- package/host-cp/src/resolver.mjs +121 -0
- package/host-cp/src/router.mjs +168 -0
- package/host-cp/src/serve-only-config.mjs +85 -0
- package/host-cp/src/server.mjs +375 -205
- package/host-cp/src/world-services.mjs +136 -0
- 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
|
+
}
|