miravo 0.4.0 → 0.6.0

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 (161) hide show
  1. package/README.md +1 -1
  2. package/console-dist/assets/activity-DsWeRq4h.js +1 -0
  3. package/console-dist/assets/api-4AqKvsm9.js +1 -0
  4. package/console-dist/assets/arrow-right-Ea_UhTNi.js +1 -0
  5. package/console-dist/assets/arrow-up-down-DFSjzzl2.js +1 -0
  6. package/console-dist/assets/blueprint-card-classes-DBFCDy0M.js +1 -0
  7. package/console-dist/assets/box-C1WaoOMF.js +1 -0
  8. package/console-dist/assets/card-BjeP32PN.js +1 -0
  9. package/console-dist/assets/catalog-BJzyzbLR.js +1 -0
  10. package/console-dist/assets/catalog-DKjEm-c4.js +1 -0
  11. package/console-dist/assets/catalog-template-icons-BrUeHaCY.js +1 -0
  12. package/console-dist/assets/checkbox-DZlJXTE4.js +1 -0
  13. package/console-dist/assets/chunk-OE4NN4TA-CEjpfhS6.js +3 -0
  14. package/console-dist/assets/combobox-BGnuCYxO.js +1 -0
  15. package/console-dist/assets/control-CPhvwx9o.js +1 -0
  16. package/console-dist/assets/control-Cg2up9NA.js +1 -0
  17. package/console-dist/assets/copy-button-WkbNkMcd.js +1 -0
  18. package/console-dist/assets/createLucideIcon-Bwxw859K.js +1 -0
  19. package/console-dist/assets/deploy-tab-JeWDwPoZ.js +1 -0
  20. package/console-dist/assets/dist-BhbDML0a.js +1 -0
  21. package/console-dist/assets/dist-Cy8XTNFT.js +2 -0
  22. package/console-dist/assets/download-DQ6NH16U.js +1 -0
  23. package/console-dist/assets/engine-store-CrLnz692.js +1 -0
  24. package/console-dist/assets/events-CXm29Ok4.js +1 -0
  25. package/console-dist/assets/events-D01IxhnP.js +1 -0
  26. package/console-dist/assets/fleet-CfxbKXt9.js +1 -0
  27. package/console-dist/assets/fleet-lotMGVqe.js +1 -0
  28. package/console-dist/assets/format-kXTonpDJ.js +1 -0
  29. package/console-dist/assets/gauge-Ci8zNZlk.js +1 -0
  30. package/console-dist/assets/get-error-message-3Q5Q-2G5.js +1 -0
  31. package/console-dist/assets/index-CVPKSDvJ.js +12 -0
  32. package/console-dist/assets/index-DJkgDeun.css +2 -0
  33. package/console-dist/assets/input-BWEQFptx.js +1 -0
  34. package/console-dist/assets/instance-detail-C5IVwuh3.js +1 -0
  35. package/console-dist/assets/label-uephX5ZG.js +1 -0
  36. package/console-dist/assets/layers-CUsLQ1fq.js +1 -0
  37. package/console-dist/assets/login-C3RJli0m.js +126 -0
  38. package/console-dist/assets/models-B_UioWQ5.js +1 -0
  39. package/console-dist/assets/models-_IPlPiZ7.js +1 -0
  40. package/console-dist/assets/not-found-B8gAm08S.js +1 -0
  41. package/console-dist/assets/platform-targets-R3T-4eJ7.js +1 -0
  42. package/console-dist/assets/platforms-BWh0BQ3Y.js +1 -0
  43. package/console-dist/assets/platforms-DStNbtVc.js +1 -0
  44. package/console-dist/assets/proxy-CsnYqnPp.js +9 -0
  45. package/console-dist/assets/query-keys-BvtE7BMs.js +1 -0
  46. package/console-dist/assets/radio-CoHegOv5.js +1 -0
  47. package/console-dist/assets/rolldown-runtime-COnpUsM8.js +1 -0
  48. package/console-dist/assets/select-CeO4vP0G.js +1 -0
  49. package/console-dist/assets/select-display-items-CIzY8953.js +1 -0
  50. package/console-dist/assets/server-D9Kfn8fQ.js +1 -0
  51. package/console-dist/assets/shallow-6lGjQhtJ.js +1 -0
  52. package/console-dist/assets/tags-CiXOIHtY.js +4 -0
  53. package/console-dist/assets/tags-FQCXIxrP.js +1 -0
  54. package/console-dist/assets/tooltip-HGLF366Q.js +1 -0
  55. package/console-dist/assets/transition-nav-link-Df4Rk6vV.js +1 -0
  56. package/console-dist/assets/transition-types-CPlGtM8w.js +1 -0
  57. package/console-dist/assets/triangle-alert-adNk_sea.js +1 -0
  58. package/console-dist/assets/use-platform-stream-DGbK1k2L.js +2 -0
  59. package/console-dist/assets/use-transition-navigate-CCpcWXB0.js +2 -0
  60. package/console-dist/assets/validation-Blvud9_O.js +1 -0
  61. package/console-dist/assets/vendor-charts-CAZdaPlq.js +60 -0
  62. package/console-dist/assets/vendor-table-wd8JuMmT.js +4 -0
  63. package/console-dist/assets/vendor-ui-ByahnaY0.js +1 -0
  64. package/console-dist/index.html +70 -2
  65. package/package.json +9 -8
  66. package/src/__tests__/integration/executable-methods-runner.ts +7 -6
  67. package/src/__tests__/integration/executable-methods.integration.ts +214 -122
  68. package/src/__tests__/integration/opcua-concurrent-clients.integration.ts +314 -0
  69. package/src/commands/adapter.ts +180 -7
  70. package/src/commands/apply.test.ts +62 -0
  71. package/src/commands/apply.ts +216 -0
  72. package/src/commands/cleanup.test.ts +34 -0
  73. package/src/commands/cleanup.ts +201 -0
  74. package/src/commands/export.test.ts +51 -0
  75. package/src/commands/export.ts +404 -0
  76. package/src/commands/fork.ts +2 -2
  77. package/src/commands/inject.ts +2 -2
  78. package/src/commands/load.ts +1 -1
  79. package/src/commands/logs.ts +13 -15
  80. package/src/commands/models.test.ts +1 -1
  81. package/src/commands/models.ts +1 -1
  82. package/src/commands/pause.ts +3 -3
  83. package/src/commands/platforms.test.ts +186 -0
  84. package/src/commands/platforms.ts +371 -0
  85. package/src/commands/remove.ts +1 -1
  86. package/src/commands/reset.ts +2 -2
  87. package/src/commands/spawn.ts +2 -2
  88. package/src/commands/speed.ts +2 -2
  89. package/src/commands/start/adapter-config.ts +157 -0
  90. package/src/commands/start/catalog-le-dt.test.ts +50 -0
  91. package/src/commands/start/catalog-le-dt.ts +187 -0
  92. package/src/commands/start/catalog-model-schema.ts +89 -0
  93. package/src/commands/start/catalog-template-detail.ts +54 -0
  94. package/src/commands/start/daemon.ts +255 -0
  95. package/src/commands/start/display.ts +150 -0
  96. package/src/commands/start/engine-setup.ts +247 -0
  97. package/src/commands/start/events.ts +126 -0
  98. package/src/commands/start/index.ts +897 -0
  99. package/src/commands/start/protocol-plans.ts +239 -0
  100. package/src/commands/start/template-loader.ts +197 -0
  101. package/src/commands/start/template-resolver.ts +185 -0
  102. package/src/commands/start/types.ts +73 -0
  103. package/src/commands/start.test.ts +137 -16
  104. package/src/commands/status.ts +1 -1
  105. package/src/commands/stop.ts +19 -8
  106. package/src/commands/templates.test.ts +1 -1
  107. package/src/commands/templates.ts +1 -3
  108. package/src/index.ts +11 -3
  109. package/src/lib/admin-auth.ts +157 -0
  110. package/src/lib/admin-client.test.ts +31 -15
  111. package/src/lib/admin-client.ts +106 -6
  112. package/src/lib/admin-platform-routes.ts +1017 -0
  113. package/src/lib/admin-platform-stream.test.ts +434 -0
  114. package/src/lib/admin-platform-stream.ts +788 -0
  115. package/src/lib/admin-responses.ts +211 -0
  116. package/src/lib/admin-server.test.ts +274 -55
  117. package/src/lib/admin-server.ts +387 -248
  118. package/src/lib/auth-store.ts +87 -0
  119. package/src/lib/daemon-registry.ts +12 -14
  120. package/src/lib/le/le-apply.test.ts +591 -0
  121. package/src/lib/le/le-apply.ts +832 -0
  122. package/src/lib/le/le-auth.ts +89 -0
  123. package/src/lib/le/le-cleanup.test.ts +168 -0
  124. package/src/lib/le/le-cleanup.ts +399 -0
  125. package/src/lib/le/le-cli-helpers.ts +77 -0
  126. package/src/lib/le/le-client.ts +184 -0
  127. package/src/lib/le/le-concurrency.test.ts +157 -0
  128. package/src/lib/le/le-concurrency.ts +78 -0
  129. package/src/lib/le/le-connection-test.ts +42 -0
  130. package/src/lib/le/le-devicehub-properties.test.ts +53 -0
  131. package/src/lib/le/le-devicehub-properties.ts +33 -0
  132. package/src/lib/le/le-devicehub.ts +213 -0
  133. package/src/lib/le/le-digital-twins.ts +285 -0
  134. package/src/lib/le/le-drivers.ts +23 -0
  135. package/src/lib/le/le-endpoint.ts +40 -0
  136. package/src/lib/le/le-environments.test.ts +73 -0
  137. package/src/lib/le/le-environments.ts +156 -0
  138. package/src/lib/le/le-markers.ts +87 -0
  139. package/src/lib/le/le-plan.test.ts +26 -0
  140. package/src/lib/le/le-plan.ts +109 -0
  141. package/src/lib/le/le-platform-operations.test.ts +198 -0
  142. package/src/lib/le/le-platform-operations.ts +340 -0
  143. package/src/lib/le/le-static-model-schema.ts +78 -0
  144. package/src/lib/le/le-static-planner.test.ts +255 -0
  145. package/src/lib/le/le-static-planner.ts +442 -0
  146. package/src/lib/le/le-template-exporter.test.ts +527 -0
  147. package/src/lib/le/le-template-exporter.ts +459 -0
  148. package/src/lib/le/le-value-types.ts +23 -0
  149. package/src/lib/mqtt-client.ts +1 -2
  150. package/src/lib/resolve-simulation.ts +1 -6
  151. package/src/lib/session-store.ts +110 -0
  152. package/src/lib/shutdown.test.ts +184 -2
  153. package/src/lib/shutdown.ts +104 -34
  154. package/src/lib/template-category.ts +3 -3
  155. package/src/lib/template-locator.ts +8 -2
  156. package/src/lib/ws-hub.ts +72 -46
  157. package/src/logger.ts +39 -14
  158. package/src/output.ts +3 -0
  159. package/console-dist/assets/index-C3XMYq26.css +0 -2
  160. package/console-dist/assets/index-hUJSpKtB.js +0 -119
  161. package/src/commands/start.ts +0 -2023
@@ -6,11 +6,35 @@ import type {
6
6
  EventLogQuery,
7
7
  EventLogResult,
8
8
  InstanceDetail,
9
+ Manifest,
9
10
  ModelSchema,
10
11
  TemplateRun,
11
12
  } from "@miravo/core";
12
- import type { CatalogTemplate, CommandResult, EngineCommand, LoggedEvent } from "@miravo/shared";
13
- import { EngineCommandSchema } from "@miravo/shared";
13
+ import type {
14
+ CatalogModel,
15
+ CatalogTemplate,
16
+ CatalogTemplateDetail,
17
+ CommandResult,
18
+ ConsoleBootstrapResponse,
19
+ EngineCommand,
20
+ LoggedEvent,
21
+ RuntimeSnapshot,
22
+ } from "@miravo/shared";
23
+ import { handleAuthStatus, handleLogin, handleLogout, handlePasswordChange } from "./admin-auth.ts";
24
+ import { handlePlatformRoute } from "./admin-platform-routes.ts";
25
+ import type { HealthResponseBody } from "./admin-responses.ts";
26
+ import {
27
+ buildHealthBody,
28
+ checkContentType,
29
+ jsonResponse,
30
+ livenessStatus,
31
+ parseCommandRequest,
32
+ plainResponse,
33
+ readinessStatus,
34
+ yamlResponse,
35
+ } from "./admin-responses.ts";
36
+ import type { SessionStore } from "./session-store.ts";
37
+ import { extractSessionId } from "./session-store.ts";
14
38
  import type { WSClientData } from "./ws-hub.ts";
15
39
  import { ALL_CHANNELS, WSHub } from "./ws-hub.ts";
16
40
 
@@ -22,6 +46,8 @@ const VALID_EVENT_TYPES: ReadonlySet<string> = new Set<LoggedEvent["type"]>([
22
46
  "instance:created",
23
47
  "instance:removed",
24
48
  "engine:state-changed",
49
+ "platform:resource",
50
+ "platform:operation",
25
51
  ]);
26
52
 
27
53
  interface AdminEngine {
@@ -42,6 +68,7 @@ interface AdminEngine {
42
68
  getModelSchema(name: string): ModelSchema | undefined;
43
69
  getEventLog(query?: EventLogQuery): EventLogResult;
44
70
  getRunningTemplates(): readonly TemplateRun[];
71
+ getManifest(): Manifest;
45
72
  }
46
73
 
47
74
  /** Network binding configuration. */
@@ -59,8 +86,20 @@ interface AdminServerProtocolOptions {
59
86
  /** WebSocket streaming configuration. When provided, enables the /ws endpoint. */
60
87
  interface AdminServerWSOptions {
61
88
  readonly bus: import("@miravo/core").EventBus;
62
- readonly getEngineState: () => import("@miravo/shared").EngineRunState;
63
- readonly getTickInfo: () => { tick: number; simulationTime: number; instanceCount: number };
89
+ }
90
+
91
+ /** Authentication configuration for the admin server. */
92
+ interface AdminServerAuth {
93
+ /** Verify a plaintext password against the stored hash. */
94
+ readonly verifyPassword: (password: string) => Promise<boolean>;
95
+ /** True when the effective password is the literal default "admin". */
96
+ readonly isDefaultPassword: boolean;
97
+ /** Server-side session store. */
98
+ readonly sessionStore: SessionStore;
99
+ /** Change the admin password (verifies old, hashes + persists new). */
100
+ readonly changePassword: (oldPassword: string, newPassword: string) => Promise<void>;
101
+ /** When true, all requests are treated as authenticated. No session required. */
102
+ readonly anonymousMode?: boolean;
64
103
  }
65
104
 
66
105
  interface AdminServerOptions {
@@ -69,12 +108,33 @@ interface AdminServerOptions {
69
108
  readonly protocols: AdminServerProtocolOptions;
70
109
  /** WebSocket streaming. When provided, enables /ws endpoint. */
71
110
  readonly ws?: AdminServerWSOptions | undefined;
72
- /** Bearer token for mutation endpoints. When set, POST /commands requires Authorization header. */
73
- readonly adminToken?: string | undefined;
111
+ /** Authentication configuration. */
112
+ readonly auth: AdminServerAuth;
113
+ /** Event log for platform operation tracking. */
114
+ readonly eventLog?: import("@miravo/core").EventLog | undefined;
74
115
  /** Path to console static assets directory. When set, serves the SPA at non-API routes. */
75
116
  readonly consoleDir?: string | undefined;
76
117
  /** Template catalog provider for GET /catalog/templates. */
77
118
  readonly getCatalog?: (() => readonly CatalogTemplate[]) | undefined;
119
+ /** Model catalog provider for GET /catalog/models. */
120
+ readonly getCatalogModels?: (() => Promise<readonly CatalogModel[]>) | undefined;
121
+ /** Model schema from YAML for GET /catalog/models/:name. */
122
+ readonly getCatalogModelSchema?: ((name: string) => Promise<ModelSchema | null>) | undefined;
123
+ /** Raw YAML text for GET /catalog/models/:name/yaml. */
124
+ readonly getModelYaml?: ((name: string) => Promise<string | null>) | undefined;
125
+ /** LE Digital Twin JSON for GET /catalog/models/:name/le-dt.json. */
126
+ readonly getLeDtJson?: ((name: string) => Promise<string | null>) | undefined;
127
+ /** Template detail with topology for GET /catalog/templates/:id. */
128
+ readonly getTemplateDetail?: ((id: string) => Promise<CatalogTemplateDetail | null>) | undefined;
129
+ /** Full LE export (JSON + CSV) for GET /catalog/templates/:id/le-template.json and /le-tags.csv. */
130
+ readonly getLeExport?:
131
+ | ((
132
+ id: string,
133
+ opts: { host: string; protocol: import("@miravo/shared").ProtocolName | undefined },
134
+ ) => Promise<{ templateJson: string; tagsCSV: string } | null>)
135
+ | undefined;
136
+ /** Raw YAML text for GET /catalog/templates/:id/yaml. */
137
+ readonly getTemplateYaml?: ((id: string) => Promise<string | null>) | undefined;
78
138
  /** Simulation name for daemon identity verification via /readyz. */
79
139
  readonly simulationName?: string | undefined;
80
140
  /** Run ID for daemon identity verification via /readyz. */
@@ -83,175 +143,12 @@ interface AdminServerOptions {
83
143
  readonly requestShutdown?: ((reason: string) => void) | undefined;
84
144
  }
85
145
 
86
- interface AdminServerStatus {
87
- readonly status: "ok" | "fail";
88
- readonly reason?: string;
89
- }
90
-
91
146
  interface AdminServerHandle {
92
147
  readonly endpoint: string;
93
148
  readonly wsHub: WSHub | null;
94
149
  stop(): void;
95
150
  }
96
151
 
97
- interface HealthResponseBody {
98
- readonly status: "ok" | "fail";
99
- readonly simulationState: EngineCoreMetrics["state"];
100
- readonly tickCount: number;
101
- readonly mqttEnabled: boolean;
102
- readonly mqttConnected: boolean | null;
103
- readonly opcuaEnabled: boolean;
104
- readonly opcuaConnected: boolean | null;
105
- readonly stalled: boolean;
106
- readonly reason?: string;
107
- readonly simulationName?: string;
108
- readonly runId?: string;
109
- }
110
-
111
- interface AdminErrorResponse {
112
- readonly message: string;
113
- readonly error: string;
114
- }
115
-
116
- function jsonResponse(body: unknown, status: number): Response {
117
- return new Response(JSON.stringify(body), {
118
- status,
119
- headers: {
120
- "content-type": "application/json; charset=utf-8",
121
- "cache-control": "no-store",
122
- },
123
- });
124
- }
125
-
126
- function plainResponse(body: string, status: number, allow?: string): Response {
127
- return new Response(body, {
128
- status,
129
- headers: {
130
- "content-type": "text/plain; charset=utf-8",
131
- "cache-control": "no-store",
132
- ...(allow ? { allow } : {}),
133
- },
134
- });
135
- }
136
-
137
- function livenessStatus(input: {
138
- readonly core: EngineCoreMetrics;
139
- readonly stalled: boolean;
140
- }): AdminServerStatus {
141
- if (input.core.state === "stopped") {
142
- return { status: "fail", reason: 'engine state is "stopped"' };
143
- }
144
- if (input.stalled) {
145
- return { status: "fail", reason: "tick loop stalled" };
146
- }
147
- return { status: "ok" };
148
- }
149
-
150
- function readinessStatus(input: {
151
- readonly core: EngineCoreMetrics;
152
- readonly stalled: boolean;
153
- readonly mqttEnabled: boolean;
154
- readonly mqttConnected: boolean;
155
- readonly opcuaEnabled: boolean;
156
- readonly opcuaConnected: boolean;
157
- }): AdminServerStatus {
158
- if (input.core.state !== "running") {
159
- return { status: "fail", reason: `engine state is "${input.core.state}"` };
160
- }
161
- if (input.stalled) {
162
- return { status: "fail", reason: "tick loop stalled" };
163
- }
164
- if (input.mqttEnabled && !input.mqttConnected) {
165
- return { status: "fail", reason: "mqtt disconnected" };
166
- }
167
- if (input.opcuaEnabled && !input.opcuaConnected) {
168
- return { status: "fail", reason: "opcua disconnected" };
169
- }
170
- return { status: "ok" };
171
- }
172
-
173
- function commandValidationErrorResponse(
174
- commandType: string,
175
- correlationId: string | undefined,
176
- issues: string,
177
- ): Response {
178
- return jsonResponse(
179
- {
180
- success: false,
181
- command: commandType,
182
- correlationId,
183
- message: `Command validation failed: ${issues}`,
184
- error:
185
- "Check the command shape. Required: { type, ...fields }. See documentation for valid command types.",
186
- },
187
- 400,
188
- );
189
- }
190
-
191
- function extractCorrelationId(payload: unknown): string | undefined {
192
- if (typeof payload !== "object" || payload === null) {
193
- return undefined;
194
- }
195
- const maybeCorrelationId = (payload as Record<string, unknown>)["correlationId"];
196
- return typeof maybeCorrelationId === "string" ? maybeCorrelationId : undefined;
197
- }
198
-
199
- function extractCommandType(payload: unknown): string {
200
- if (typeof payload !== "object" || payload === null) {
201
- return "unknown";
202
- }
203
- const maybeType = (payload as Record<string, unknown>)["type"];
204
- return typeof maybeType === "string" ? maybeType : "unknown";
205
- }
206
-
207
- function buildHealthBody(input: {
208
- readonly metrics: { readonly core: EngineCoreMetrics };
209
- readonly stalled: boolean;
210
- readonly mqttEnabled: boolean;
211
- readonly mqttConnected: boolean;
212
- readonly opcuaEnabled: boolean;
213
- readonly opcuaConnected: boolean;
214
- }): Omit<HealthResponseBody, "status" | "reason"> {
215
- return {
216
- simulationState: input.metrics.core.state,
217
- tickCount: input.metrics.core.tickCount,
218
- mqttEnabled: input.mqttEnabled,
219
- mqttConnected: input.mqttEnabled ? input.mqttConnected : null,
220
- opcuaEnabled: input.opcuaEnabled,
221
- opcuaConnected: input.opcuaEnabled ? input.opcuaConnected : null,
222
- stalled: input.stalled,
223
- };
224
- }
225
-
226
- async function parseCommandRequest(request: Request): Promise<Response | EngineCommand> {
227
- let payload: unknown;
228
- try {
229
- payload = await request.json();
230
- } catch {
231
- return jsonResponse(
232
- {
233
- message: "Invalid JSON in command request body.",
234
- error: 'Send a valid JSON object. Example: {"type":"pause"}',
235
- } satisfies AdminErrorResponse,
236
- 400,
237
- );
238
- }
239
-
240
- const validation = EngineCommandSchema.safeParse(payload);
241
- if (!validation.success) {
242
- const issues = validation.error.issues
243
- .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
244
- .join("; ");
245
- return commandValidationErrorResponse(
246
- extractCommandType(payload),
247
- extractCorrelationId(payload),
248
- issues,
249
- );
250
- }
251
-
252
- return validation.data;
253
- }
254
-
255
152
  /**
256
153
  * Start the Miravo admin HTTP server.
257
154
  *
@@ -266,73 +163,42 @@ function startAdminServer(options: AdminServerOptions): AdminServerHandle {
266
163
  const { host, port } = options.network;
267
164
  const { getLastTickProgressAt } = options.protocols;
268
165
 
269
- const adminToken = options.adminToken;
270
- const isLoopback = host === "127.0.0.1" || host === "localhost" || host === "::1";
271
- const isWildcard = host === "0.0.0.0" || host === "::";
272
-
273
- /** Check bearer token on mutation endpoints. Returns error Response or null if OK. */
274
- function checkAuth(request: Request): Response | null {
275
- if (!adminToken) return null;
276
- const authHeader = request.headers.get("authorization");
277
- if (authHeader === `Bearer ${adminToken}`) return null;
278
- return jsonResponse(
279
- { message: "Unauthorized.", error: "Provide a valid Authorization: Bearer <token> header." },
280
- 401,
281
- );
282
- }
166
+ const { auth } = options;
283
167
 
284
- /** Require an admin token for remote mutation endpoints. */
285
- function checkRemoteMutationAuthConfigured(): Response | null {
286
- if (isLoopback || adminToken) return null;
287
- return jsonResponse(
288
- {
289
- message: "Mutation endpoints require authentication.",
290
- error: "Set MIRAVO_ADMIN_TOKEN when binding to a non-loopback address.",
291
- },
292
- 403,
293
- );
168
+ function getSession(request: Request) {
169
+ const sessionId = extractSessionId(request);
170
+ return sessionId ? auth.sessionStore.validate(sessionId) : null;
294
171
  }
295
172
 
296
- /**
297
- * Require Content-Type: application/json on mutation endpoints.
298
- * This forces cross-origin requests through a CORS preflight (since application/json
299
- * is not a CORS-safe content type), which blocks form CSRF and no-cors fetch attacks.
300
- */
301
- function checkContentType(request: Request): Response | null {
302
- const ct = request.headers.get("content-type");
303
- if (ct && ct.split(";")[0]?.trim() === "application/json") return null;
304
- return jsonResponse(
305
- {
306
- message: "Unsupported Media Type.",
307
- error: "Content-Type must be application/json.",
308
- },
309
- 415,
310
- );
311
- }
312
-
313
- /** Check Origin header on mutation endpoints for non-loopback binds. Returns error Response or null if OK. */
314
- function checkOrigin(request: Request): Response | null {
315
- // Loopback and wildcard binds skip Origin checking — wildcard (0.0.0.0)
316
- // accepts connections from any interface, so we can't match a specific host.
317
- if (isLoopback || isWildcard) return null;
318
- const origin = request.headers.get("origin");
319
- if (!origin) return null; // No origin = non-browser client, allow
320
- try {
321
- const originHost = new URL(origin).hostname;
322
- if (originHost === host || originHost === "localhost" || originHost === "127.0.0.1")
323
- return null;
324
- } catch {
325
- // Malformed origin
173
+ /** Validate session cookie. Returns error Response or null if OK. */
174
+ function checkSession(request: Request): Response | null {
175
+ if (options.auth.anonymousMode) return null;
176
+ const session = getSession(request);
177
+ if (!session) {
178
+ const sessionId = extractSessionId(request);
179
+ if (!sessionId) {
180
+ return jsonResponse(
181
+ { message: "Unauthorized.", error: "Valid session required. Log in at /auth/login." },
182
+ 401,
183
+ );
184
+ }
185
+ return jsonResponse(
186
+ { message: "Session expired.", error: "Log in again at /auth/login." },
187
+ 401,
188
+ );
326
189
  }
327
- return jsonResponse({ message: "Forbidden.", error: "Origin not allowed." }, 403);
190
+ return null;
328
191
  }
329
192
 
330
193
  // Create WebSocket hub if ws config is provided
331
194
  const wsHub = options.ws
332
195
  ? new WSHub({
333
196
  bus: options.ws.bus,
334
- getEngineState: options.ws.getEngineState,
335
- getTickInfo: options.ws.getTickInfo,
197
+ snapshotSource: {
198
+ getState: () => engine.getState(),
199
+ getMetrics: () => engine.getMetrics(),
200
+ getRunningTemplates: () => engine.getRunningTemplates(),
201
+ },
336
202
  })
337
203
  : null;
338
204
 
@@ -355,8 +221,8 @@ function startAdminServer(options: AdminServerOptions): AdminServerHandle {
355
221
  if (!wsHub) {
356
222
  return plainResponse("WebSocket not available", 503);
357
223
  }
358
- const originBlock = checkOrigin(request);
359
- if (originBlock) return originBlock;
224
+ const sessionBlock = checkSession(request);
225
+ if (sessionBlock) return sessionBlock;
360
226
  const upgraded = server.upgrade(request, {
361
227
  // WSClientData — Bun's untyped websocket generic requires the cast
362
228
  data: { channels: new Set(ALL_CHANNELS) } as unknown as undefined,
@@ -377,6 +243,8 @@ function startAdminServer(options: AdminServerOptions): AdminServerHandle {
377
243
  const metrics = engine.getMetrics();
378
244
 
379
245
  if (pathname === "/metrics") {
246
+ const metricsAuth = checkSession(request);
247
+ if (metricsAuth) return metricsAuth;
380
248
  return jsonResponse(metrics, 200);
381
249
  }
382
250
 
@@ -448,21 +316,65 @@ function startAdminServer(options: AdminServerOptions): AdminServerHandle {
448
316
  if (request.method !== "GET") {
449
317
  return plainResponse("Method Not Allowed", 405, "GET");
450
318
  }
319
+ const stateAuth = checkSession(request);
320
+ if (stateAuth) return stateAuth;
451
321
  return jsonResponse(engine.getState(), 200);
452
322
  }
453
323
 
324
+ if (pathname === "/bootstrap") {
325
+ if (request.method !== "GET") {
326
+ return plainResponse("Method Not Allowed", 405, "GET");
327
+ }
328
+ if (options.auth.anonymousMode) {
329
+ const runtime: RuntimeSnapshot = wsHub
330
+ ? wsHub.buildRuntimeSnapshot()
331
+ : (JSON.parse(
332
+ JSON.stringify({
333
+ state: engine.getState(),
334
+ metrics: engine.getMetrics(),
335
+ runningTemplates: engine.getRunningTemplates(),
336
+ revision: 0,
337
+ }),
338
+ ) as RuntimeSnapshot);
339
+ return jsonResponse(
340
+ {
341
+ authenticated: true,
342
+ username: "anonymous",
343
+ runtime,
344
+ } satisfies ConsoleBootstrapResponse,
345
+ 200,
346
+ );
347
+ }
348
+ const session = getSession(request);
349
+ if (!session) {
350
+ return jsonResponse({ authenticated: false } satisfies ConsoleBootstrapResponse, 200);
351
+ }
352
+ const runtime: RuntimeSnapshot = wsHub
353
+ ? wsHub.buildRuntimeSnapshot()
354
+ : (JSON.parse(
355
+ JSON.stringify({
356
+ state: engine.getState(),
357
+ metrics: engine.getMetrics(),
358
+ runningTemplates: engine.getRunningTemplates(),
359
+ revision: 0,
360
+ }),
361
+ ) as RuntimeSnapshot);
362
+ const body: ConsoleBootstrapResponse = {
363
+ authenticated: true,
364
+ username: session.username,
365
+ runtime,
366
+ };
367
+ return jsonResponse(body, 200);
368
+ }
369
+
454
370
  if (pathname === "/commands") {
455
371
  if (request.method !== "POST") {
456
372
  return plainResponse("Method Not Allowed", 405, "POST");
457
373
  }
458
- const authConfigBlock = checkRemoteMutationAuthConfigured();
459
- if (authConfigBlock) return authConfigBlock;
374
+ const cmdAuth = checkSession(request);
375
+ if (cmdAuth) return cmdAuth;
460
376
  const ctBlock = checkContentType(request);
461
377
  if (ctBlock) return ctBlock;
462
- const originBlock = checkOrigin(request);
463
- if (originBlock) return originBlock;
464
- const authBlock = checkAuth(request);
465
- if (authBlock) return authBlock;
466
378
  const commandOrError = await parseCommandRequest(request);
467
379
  if (commandOrError instanceof Response) {
468
380
  return commandOrError;
@@ -484,14 +396,10 @@ function startAdminServer(options: AdminServerOptions): AdminServerHandle {
484
396
  501,
485
397
  );
486
398
  }
487
- const authConfigBlock = checkRemoteMutationAuthConfigured();
488
- if (authConfigBlock) return authConfigBlock;
399
+ const shutdownAuth = checkSession(request);
400
+ if (shutdownAuth) return shutdownAuth;
489
401
  const shutdownCtBlock = checkContentType(request);
490
402
  if (shutdownCtBlock) return shutdownCtBlock;
491
- const originBlock = checkOrigin(request);
492
- if (originBlock) return originBlock;
493
- const authBlock = checkAuth(request);
494
- if (authBlock) return authBlock;
495
403
 
496
404
  let reason = "remote shutdown request";
497
405
  try {
@@ -517,6 +425,8 @@ function startAdminServer(options: AdminServerOptions): AdminServerHandle {
517
425
  if (request.method !== "GET") {
518
426
  return plainResponse("Method Not Allowed", 405, "GET");
519
427
  }
428
+ const instanceAuth = checkSession(request);
429
+ if (instanceAuth) return instanceAuth;
520
430
  const instanceId = pathname.slice("/instances/".length);
521
431
  if (!instanceId) {
522
432
  return jsonResponse(
@@ -539,6 +449,8 @@ function startAdminServer(options: AdminServerOptions): AdminServerHandle {
539
449
  if (request.method !== "GET") {
540
450
  return plainResponse("Method Not Allowed", 405, "GET");
541
451
  }
452
+ const modelAuth = checkSession(request);
453
+ if (modelAuth) return modelAuth;
542
454
  const modelName = pathname.slice("/models/".length);
543
455
  if (!modelName) {
544
456
  return jsonResponse({ message: "Missing model name.", error: "Use /models/{name}" }, 400);
@@ -558,6 +470,8 @@ function startAdminServer(options: AdminServerOptions): AdminServerHandle {
558
470
  if (request.method !== "GET") {
559
471
  return plainResponse("Method Not Allowed", 405, "GET");
560
472
  }
473
+ const eventsAuth = checkSession(request);
474
+ if (eventsAuth) return eventsAuth;
561
475
  const typeParam = url.searchParams.get("type");
562
476
  if (typeParam && !VALID_EVENT_TYPES.has(typeParam)) {
563
477
  return jsonResponse(
@@ -583,11 +497,197 @@ function startAdminServer(options: AdminServerOptions): AdminServerHandle {
583
497
  return jsonResponse(engine.getEventLog(query), 200);
584
498
  }
585
499
 
500
+ // GET /catalog/models
501
+ if (pathname === "/catalog/models") {
502
+ if (request.method !== "GET") return plainResponse("Method Not Allowed", 405, "GET");
503
+ const auth = checkSession(request);
504
+ if (auth) return auth;
505
+ const models = options.getCatalogModels ? await options.getCatalogModels() : [];
506
+ return jsonResponse(models, 200);
507
+ }
508
+
509
+ // GET /catalog/models/:name/le-dt.json
510
+ if (pathname.startsWith("/catalog/models/") && pathname.endsWith("/le-dt.json")) {
511
+ if (request.method !== "GET") return plainResponse("Method Not Allowed", 405, "GET");
512
+ const auth = checkSession(request);
513
+ if (auth) return auth;
514
+ const name = decodeURIComponent(
515
+ pathname.slice("/catalog/models/".length, -"/le-dt.json".length),
516
+ );
517
+ if (
518
+ !name ||
519
+ name.includes("/") ||
520
+ name.includes("\\") ||
521
+ name.includes("..") ||
522
+ !options.getLeDtJson
523
+ )
524
+ return jsonResponse({ message: "Not found.", error: "Model not found." }, 404);
525
+ const leDtJson = await options.getLeDtJson(name);
526
+ if (!leDtJson)
527
+ return jsonResponse(
528
+ { message: `Model "${name}" not found.`, error: "Check the model name." },
529
+ 404,
530
+ );
531
+ return jsonResponse(JSON.parse(leDtJson), 200);
532
+ }
533
+
534
+ // GET /catalog/models/:name/yaml
535
+ if (pathname.startsWith("/catalog/models/") && pathname.endsWith("/yaml")) {
536
+ if (request.method !== "GET") return plainResponse("Method Not Allowed", 405, "GET");
537
+ const auth = checkSession(request);
538
+ if (auth) return auth;
539
+ const name = decodeURIComponent(pathname.slice("/catalog/models/".length, -"/yaml".length));
540
+ if (
541
+ !name ||
542
+ name.includes("/") ||
543
+ name.includes("\\") ||
544
+ name.includes("..") ||
545
+ !options.getModelYaml
546
+ )
547
+ return jsonResponse({ message: "Not found.", error: "Model not found." }, 404);
548
+ const yaml = await options.getModelYaml(name);
549
+ if (!yaml)
550
+ return jsonResponse(
551
+ { message: `Model "${name}" not found.`, error: "Check the model name." },
552
+ 404,
553
+ );
554
+ return yamlResponse(yaml, 200);
555
+ }
556
+
557
+ // GET /catalog/models/:name (schema)
558
+ if (pathname.startsWith("/catalog/models/")) {
559
+ if (request.method !== "GET") return plainResponse("Method Not Allowed", 405, "GET");
560
+ const auth = checkSession(request);
561
+ if (auth) return auth;
562
+ const name = decodeURIComponent(pathname.slice("/catalog/models/".length));
563
+ if (
564
+ !name ||
565
+ name.includes("/") ||
566
+ name.includes("\\") ||
567
+ name.includes("..") ||
568
+ !options.getCatalogModelSchema
569
+ )
570
+ return jsonResponse({ message: "Not found.", error: "Model not found." }, 404);
571
+ const schema = await options.getCatalogModelSchema(name);
572
+ if (!schema)
573
+ return jsonResponse(
574
+ { message: `Model "${name}" not found.`, error: "Check the model name." },
575
+ 404,
576
+ );
577
+ return jsonResponse(schema, 200);
578
+ }
579
+
580
+ // GET /catalog/templates/:id/le-template.json and /le-tags.csv
581
+ if (
582
+ pathname.startsWith("/catalog/templates/") &&
583
+ (pathname.endsWith("/le-template.json") || pathname.endsWith("/le-tags.csv"))
584
+ ) {
585
+ if (request.method !== "GET") return plainResponse("Method Not Allowed", 405, "GET");
586
+ const auth = checkSession(request);
587
+ if (auth) return auth;
588
+ const isJson = pathname.endsWith("/le-template.json");
589
+ const suffix = isJson ? "/le-template.json" : "/le-tags.csv";
590
+ const id = decodeURIComponent(pathname.slice("/catalog/templates/".length, -suffix.length));
591
+ const host = url.searchParams.get("host")?.trim() ?? "";
592
+ const protocolParam = url.searchParams.get("protocol") ?? undefined;
593
+ if (
594
+ !id ||
595
+ id.includes("/") ||
596
+ id.includes("\\") ||
597
+ id.includes("..") ||
598
+ !options.getLeExport
599
+ )
600
+ return jsonResponse({ message: "Not found.", error: "Template not found." }, 404);
601
+ if (!host) {
602
+ return jsonResponse(
603
+ {
604
+ message: "Missing host query parameter.",
605
+ error: "Provide ?host=<hostname-or-ip> reachable from Litmus Edge.",
606
+ },
607
+ 400,
608
+ );
609
+ }
610
+ const result = await options.getLeExport(id, {
611
+ host,
612
+ protocol: protocolParam as import("@miravo/shared").ProtocolName | undefined,
613
+ });
614
+ if (!result)
615
+ return jsonResponse(
616
+ { message: `Template "${id}" not found.`, error: "Check the template ID." },
617
+ 404,
618
+ );
619
+ return isJson
620
+ ? new Response(result.templateJson, {
621
+ status: 200,
622
+ headers: {
623
+ "content-type": "application/json",
624
+ "content-disposition": `attachment; filename="${id}.le-template.json"`,
625
+ },
626
+ })
627
+ : new Response(result.tagsCSV, {
628
+ status: 200,
629
+ headers: {
630
+ "content-type": "text/csv",
631
+ "content-disposition": `attachment; filename="${id}-tags.csv"`,
632
+ },
633
+ });
634
+ }
635
+
636
+ // GET /catalog/templates/:id/yaml
637
+ if (pathname.startsWith("/catalog/templates/") && pathname.endsWith("/yaml")) {
638
+ if (request.method !== "GET") return plainResponse("Method Not Allowed", 405, "GET");
639
+ const auth = checkSession(request);
640
+ if (auth) return auth;
641
+ const id = decodeURIComponent(
642
+ pathname.slice("/catalog/templates/".length, -"/yaml".length),
643
+ );
644
+ if (
645
+ !id ||
646
+ id.includes("/") ||
647
+ id.includes("\\") ||
648
+ id.includes("..") ||
649
+ !options.getTemplateYaml
650
+ )
651
+ return jsonResponse({ message: "Not found.", error: "Template not found." }, 404);
652
+ const yaml = await options.getTemplateYaml(id);
653
+ if (!yaml)
654
+ return jsonResponse(
655
+ { message: `Template "${id}" not found.`, error: "Check the template ID." },
656
+ 404,
657
+ );
658
+ return yamlResponse(yaml, 200);
659
+ }
660
+
661
+ // GET /catalog/templates/:id (detail with topology)
662
+ if (pathname.startsWith("/catalog/templates/") && pathname !== "/catalog/templates") {
663
+ if (request.method !== "GET") return plainResponse("Method Not Allowed", 405, "GET");
664
+ const auth = checkSession(request);
665
+ if (auth) return auth;
666
+ const id = decodeURIComponent(pathname.slice("/catalog/templates/".length));
667
+ if (
668
+ !id ||
669
+ id.includes("/") ||
670
+ id.includes("\\") ||
671
+ id.includes("..") ||
672
+ !options.getTemplateDetail
673
+ )
674
+ return jsonResponse({ message: "Not found.", error: "Template not found." }, 404);
675
+ const detail = await options.getTemplateDetail(id);
676
+ if (!detail)
677
+ return jsonResponse(
678
+ { message: `Template "${id}" not found.`, error: "Check the template ID." },
679
+ 404,
680
+ );
681
+ return jsonResponse(detail, 200);
682
+ }
683
+
586
684
  // GET /catalog/templates
587
685
  if (pathname === "/catalog/templates") {
588
686
  if (request.method !== "GET") {
589
687
  return plainResponse("Method Not Allowed", 405, "GET");
590
688
  }
689
+ const catalogAuth = checkSession(request);
690
+ if (catalogAuth) return catalogAuth;
591
691
  const catalog = options.getCatalog ? options.getCatalog() : [];
592
692
  return jsonResponse(catalog, 200);
593
693
  }
@@ -597,9 +697,55 @@ function startAdminServer(options: AdminServerOptions): AdminServerHandle {
597
697
  if (request.method !== "GET") {
598
698
  return plainResponse("Method Not Allowed", 405, "GET");
599
699
  }
700
+ const rtAuth = checkSession(request);
701
+ if (rtAuth) return rtAuth;
600
702
  return jsonResponse(engine.getRunningTemplates(), 200);
601
703
  }
602
704
 
705
+ // GET /manifest
706
+ if (pathname === "/manifest") {
707
+ if (request.method !== "GET") {
708
+ return plainResponse("Method Not Allowed", 405, "GET");
709
+ }
710
+ const manifestAuth = checkSession(request);
711
+ if (manifestAuth) return manifestAuth;
712
+ return jsonResponse(engine.getManifest(), 200);
713
+ }
714
+
715
+ // --- Auth routes ---
716
+
717
+ if (pathname === "/auth/login" && request.method === "POST") {
718
+ return handleLogin(request, auth);
719
+ }
720
+
721
+ if (pathname === "/auth/logout" && request.method === "POST") {
722
+ return handleLogout(request, auth);
723
+ }
724
+
725
+ if (pathname === "/auth/password" && request.method === "PUT") {
726
+ const pwAuth = checkSession(request);
727
+ if (pwAuth) return pwAuth;
728
+ return handlePasswordChange(request, auth);
729
+ }
730
+
731
+ if (pathname === "/auth/status") {
732
+ if (request.method !== "GET") {
733
+ return plainResponse("Method Not Allowed", 405, "GET");
734
+ }
735
+ return handleAuthStatus(request, auth);
736
+ }
737
+
738
+ // --- Platform routes (LE apply/cleanup) ---
739
+ if (pathname.startsWith("/platforms/")) {
740
+ const platformResponse = await handlePlatformRoute(
741
+ pathname,
742
+ request,
743
+ checkSession,
744
+ options.eventLog,
745
+ );
746
+ if (platformResponse) return platformResponse;
747
+ }
748
+
603
749
  // --- Console static file serving ---
604
750
  if (consoleIndexFile && consoleDir) {
605
751
  if (pathname !== "/") {
@@ -641,18 +787,11 @@ function startAdminServer(options: AdminServerOptions): AdminServerHandle {
641
787
  endpoint: `http://${host}:${server.port}`,
642
788
  wsHub,
643
789
  stop() {
790
+ auth.sessionStore.clear();
644
791
  wsHub?.close();
645
792
  server.stop(true);
646
793
  },
647
794
  };
648
795
  }
649
796
 
650
- export type {
651
- AdminEngine,
652
- AdminServerHandle,
653
- AdminServerNetworkOptions,
654
- AdminServerOptions,
655
- AdminServerProtocolOptions,
656
- AdminServerWSOptions,
657
- };
658
797
  export { startAdminServer };