@wenathlan/saddle 2.0.151 → 2.0.152

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 (293) hide show
  1. package/README.md +3 -3
  2. package/biome.jsonc +12 -5
  3. package/dist/App.d.ts +3 -0
  4. package/dist/App.js +50 -0
  5. package/dist/Sol/Sol.d.ts +3 -0
  6. package/dist/Sol/Sol.js +46 -0
  7. package/dist/Sol/agentbrowser/agentbrowser.d.ts +9 -0
  8. package/dist/Sol/agentbrowser/agentbrowser.js +121 -0
  9. package/dist/Sol/architecture/architecture.d.ts +9 -0
  10. package/dist/Sol/architecture/architecture.js +93 -0
  11. package/dist/Sol/compute/compute.d.ts +9 -0
  12. package/dist/Sol/compute/compute.js +22 -0
  13. package/dist/Sol/console/console.d.ts +15 -0
  14. package/dist/Sol/console/console.js +292 -0
  15. package/dist/Sol/dashboard/dashboard.d.ts +10 -0
  16. package/dist/Sol/dashboard/dashboard.js +181 -0
  17. package/dist/Sol/docs/docs.d.ts +9 -0
  18. package/dist/Sol/docs/docs.js +28 -0
  19. package/dist/Sol/home/home.d.ts +35 -0
  20. package/dist/Sol/home/home.js +239 -0
  21. package/dist/Sol/integrations/integrations.d.ts +9 -0
  22. package/dist/Sol/integrations/integrations.js +83 -0
  23. package/dist/Sol/intro/intro.d.ts +7 -0
  24. package/dist/Sol/intro/intro.js +102 -0
  25. package/dist/Sol/login/login.d.ts +16 -0
  26. package/dist/Sol/login/login.js +49 -0
  27. package/dist/Sol/notfound/notfound.d.ts +9 -0
  28. package/dist/Sol/notfound/notfound.js +15 -0
  29. package/dist/Sol/onboarding/onboarding.d.ts +25 -0
  30. package/dist/Sol/onboarding/onboarding.js +60 -0
  31. package/dist/Sol/playground/playground.d.ts +9 -0
  32. package/dist/Sol/playground/playground.js +127 -0
  33. package/dist/Sol/register/register.d.ts +16 -0
  34. package/dist/Sol/register/register.js +193 -0
  35. package/dist/Sol/shell/Shell.d.ts +124 -0
  36. package/dist/Sol/shell/Shell.js +335 -0
  37. package/dist/Sol/wingets/ComputeBootladder.d.ts +6 -0
  38. package/dist/Sol/wingets/ComputeBootladder.js +71 -0
  39. package/dist/Sol/wingets/ComputeModalitycatalog.d.ts +7 -0
  40. package/dist/Sol/wingets/ComputeModalitycatalog.js +71 -0
  41. package/dist/Sol/wingets/ConsoleApibadge.d.ts +7 -0
  42. package/dist/Sol/wingets/ConsoleApibadge.js +5 -0
  43. package/dist/Sol/wingets/ConsoleEdgelaw.d.ts +6 -0
  44. package/dist/Sol/wingets/ConsoleEdgelaw.js +36 -0
  45. package/dist/Sol/wingets/ConsoleEventstimeline.d.ts +19 -0
  46. package/dist/Sol/wingets/ConsoleEventstimeline.js +12 -0
  47. package/dist/Sol/wingets/ConsoleSnapshotcontrols.d.ts +8 -0
  48. package/dist/Sol/wingets/ConsoleSnapshotcontrols.js +8 -0
  49. package/dist/Sol/wingets/ConsoleSpecpanel.d.ts +21 -0
  50. package/dist/Sol/wingets/ConsoleSpecpanel.js +75 -0
  51. package/dist/Sol/wingets/ConsoleTerminal.d.ts +22 -0
  52. package/dist/Sol/wingets/ConsoleTerminal.js +77 -0
  53. package/dist/Sol/wingets/DashboardAccountpanel.d.ts +18 -0
  54. package/dist/Sol/wingets/DashboardAccountpanel.js +65 -0
  55. package/dist/Sol/wingets/DashboardAdminpanel.d.ts +6 -0
  56. package/dist/Sol/wingets/DashboardAdminpanel.js +90 -0
  57. package/dist/Sol/wingets/DashboardAuditlog.d.ts +8 -0
  58. package/dist/Sol/wingets/DashboardAuditlog.js +50 -0
  59. package/dist/Sol/wingets/DashboardCreatesandboxform.d.ts +10 -0
  60. package/dist/Sol/wingets/DashboardCreatesandboxform.js +71 -0
  61. package/dist/Sol/wingets/DashboardEventstimeline.d.ts +8 -0
  62. package/dist/Sol/wingets/DashboardEventstimeline.js +83 -0
  63. package/dist/Sol/wingets/DashboardGlobalsandboxestable.d.ts +8 -0
  64. package/dist/Sol/wingets/DashboardGlobalsandboxestable.js +57 -0
  65. package/dist/Sol/wingets/DashboardMeshnodestable.d.ts +8 -0
  66. package/dist/Sol/wingets/DashboardMeshnodestable.js +103 -0
  67. package/dist/Sol/wingets/DashboardSandboxlist.d.ts +10 -0
  68. package/dist/Sol/wingets/DashboardSandboxlist.js +163 -0
  69. package/dist/Sol/wingets/DashboardUserstable.d.ts +8 -0
  70. package/dist/Sol/wingets/DashboardUserstable.js +78 -0
  71. package/dist/Sol/wingets/HomeEntrygate.d.ts +23 -0
  72. package/dist/Sol/wingets/HomeEntrygate.js +73 -0
  73. package/dist/Sol/wingets/HomeMetricstrip.d.ts +9 -0
  74. package/dist/Sol/wingets/HomeMetricstrip.js +4 -0
  75. package/dist/Sol/wingets/HomeRuntimediagram.d.ts +1 -0
  76. package/dist/Sol/wingets/HomeRuntimediagram.js +22 -0
  77. package/dist/Sol/wingets/LoginForm.d.ts +19 -0
  78. package/dist/Sol/wingets/LoginForm.js +141 -0
  79. package/dist/Sol/wingets/RegisterPasswordconfirm.d.ts +13 -0
  80. package/dist/Sol/wingets/RegisterPasswordconfirm.js +16 -0
  81. package/dist/Sol/wingets/RegisterPasswordmeter.d.ts +19 -0
  82. package/dist/Sol/wingets/RegisterPasswordmeter.js +65 -0
  83. package/dist/Sol/wingets/RegisterUsernamerules.d.ts +11 -0
  84. package/dist/Sol/wingets/RegisterUsernamerules.js +16 -0
  85. package/dist/Sol/wingets/index.d.ts +39 -0
  86. package/dist/Sol/wingets/index.js +39 -0
  87. package/dist/alternatives.d.ts +508 -0
  88. package/dist/alternatives.js +950 -0
  89. package/dist/api.d.ts +154 -0
  90. package/dist/api.js +174 -0
  91. package/dist/auth.d.ts +268 -0
  92. package/dist/auth.js +476 -0
  93. package/dist/automation.d.ts +425 -0
  94. package/dist/automation.js +566 -0
  95. package/dist/browser.d.ts +561 -0
  96. package/dist/browser.js +1058 -0
  97. package/dist/capacitor.config.d.ts +45 -0
  98. package/dist/capacitor.config.js +48 -0
  99. package/dist/catalog.d.ts +43 -0
  100. package/dist/catalog.js +44 -0
  101. package/dist/cli.d.ts +16 -0
  102. package/dist/cli.js +168 -0
  103. package/dist/communication.d.ts +181 -0
  104. package/dist/communication.js +376 -0
  105. package/dist/compute.d.ts +1160 -0
  106. package/dist/compute.js +2272 -0
  107. package/dist/dashboardlib.d.ts +58 -0
  108. package/dist/dashboardlib.js +161 -0
  109. package/dist/db.d.ts +569 -0
  110. package/dist/db.js +1080 -0
  111. package/dist/distribution.d.ts +528 -0
  112. package/dist/distribution.js +810 -0
  113. package/dist/drizzle.config.d.ts +20 -0
  114. package/dist/drizzle.config.js +18 -0
  115. package/dist/edgelandes.d.ts +70 -0
  116. package/dist/edgelandes.host.d.ts +26 -0
  117. package/dist/edgelandes.host.js +1089 -0
  118. package/dist/edgelandes.inproc.d.ts +27 -0
  119. package/dist/edgelandes.inproc.js +952 -0
  120. package/dist/edgelandes.js +1133 -0
  121. package/dist/edgelandes.ninety.d.ts +31 -0
  122. package/dist/edgelandes.ninety.js +174 -0
  123. package/dist/edgelandes.virtual.d.ts +25 -0
  124. package/dist/edgelandes.virtual.js +629 -0
  125. package/dist/edgeplane.d.ts +114 -0
  126. package/dist/edgeplane.js +305 -0
  127. package/dist/examplelocaljob.d.ts +1 -0
  128. package/{docs/example-localjob.ts → dist/examplelocaljob.js} +0 -1
  129. package/dist/execution.d.ts +529 -0
  130. package/dist/execution.js +760 -0
  131. package/dist/family.d.ts +16 -0
  132. package/dist/family.js +42 -0
  133. package/dist/familyurl.d.ts +21 -0
  134. package/dist/familyurl.js +24 -0
  135. package/dist/format.d.ts +18 -0
  136. package/dist/format.js +59 -0
  137. package/dist/foundation.d.ts +172 -0
  138. package/dist/foundation.js +312 -0
  139. package/dist/index.d.ts +735 -0
  140. package/dist/index.js +1020 -0
  141. package/dist/integration.d.ts +235 -0
  142. package/dist/integration.js +228 -0
  143. package/dist/intelligence.d.ts +93 -0
  144. package/dist/intelligence.js +144 -0
  145. package/dist/isolation.d.ts +128 -0
  146. package/dist/isolation.js +95 -0
  147. package/dist/labels.d.ts +665 -0
  148. package/dist/labels.js +751 -0
  149. package/dist/localauth.d.ts +127 -0
  150. package/dist/localauth.js +413 -0
  151. package/dist/media.d.ts +343 -0
  152. package/dist/media.js +985 -0
  153. package/dist/mesh.d.ts +177 -0
  154. package/dist/mesh.js +468 -0
  155. package/dist/modalityregistry.d.ts +109 -0
  156. package/dist/modalityregistry.js +1386 -0
  157. package/dist/modes.d.ts +358 -0
  158. package/dist/modes.js +133 -0
  159. package/dist/onboardingstate.d.ts +79 -0
  160. package/dist/onboardingstate.js +282 -0
  161. package/dist/orchestrator.d.ts +1338 -0
  162. package/dist/orchestrator.js +4310 -0
  163. package/dist/packager/targetcli.js +4 -0
  164. package/dist/paths.d.ts +2 -0
  165. package/dist/paths.js +14 -0
  166. package/dist/performance.d.ts +415 -0
  167. package/dist/performance.js +826 -0
  168. package/dist/quantum.d.ts +809 -0
  169. package/dist/quantum.js +2072 -0
  170. package/dist/render.d.ts +380 -0
  171. package/dist/render.js +1072 -0
  172. package/dist/runall.d.ts +2 -0
  173. package/dist/runall.js +39 -0
  174. package/dist/runtimeprobe.d.ts +4 -0
  175. package/dist/runtimeprobe.js +8 -0
  176. package/dist/sandbox.d.ts +355 -0
  177. package/dist/sandbox.js +1721 -0
  178. package/dist/sandboxenvprofile.d.ts +29 -0
  179. package/dist/sandboxenvprofile.js +89 -0
  180. package/dist/sandboxprofile.d.ts +137 -0
  181. package/dist/sandboxprofile.js +241 -0
  182. package/dist/sandboxqueue.d.ts +108 -0
  183. package/dist/sandboxqueue.js +201 -0
  184. package/dist/sandboxquota.d.ts +121 -0
  185. package/dist/sandboxquota.js +236 -0
  186. package/dist/scheduler.d.ts +595 -0
  187. package/dist/scheduler.js +1258 -0
  188. package/dist/scriptlanes.d.ts +182 -0
  189. package/dist/scriptlanes.js +660 -0
  190. package/dist/scripts.buildengine.d.ts +1 -0
  191. package/dist/scripts.buildengine.js +39 -0
  192. package/dist/scripts.securitygate.d.ts +1 -0
  193. package/dist/scripts.securitygate.js +68 -0
  194. package/dist/scripts.validateflatnative.d.ts +1 -0
  195. package/dist/scripts.validateflatnative.js +72 -0
  196. package/dist/security.d.ts +500 -0
  197. package/dist/security.js +1169 -0
  198. package/dist/seedcatalog.d.ts +13 -0
  199. package/dist/seedcatalog.js +46 -0
  200. package/dist/server.d.ts +20 -0
  201. package/dist/server.js +53 -0
  202. package/dist/speccatalog.d.ts +31 -0
  203. package/dist/speccatalog.js +163 -0
  204. package/dist/tiers.d.ts +1001 -0
  205. package/dist/tiers.js +2169 -0
  206. package/dist/utils.d.ts +2 -0
  207. package/dist/utils.js +5 -0
  208. package/dist/vaulthandoff.d.ts +141 -0
  209. package/dist/vaulthandoff.js +444 -0
  210. package/dist/virtual.d.ts +640 -0
  211. package/dist/virtual.js +1284 -0
  212. package/dist/virtualcpu.d.ts +315 -0
  213. package/dist/virtualcpu.js +1180 -0
  214. package/dist/virtualgpu.d.ts +344 -0
  215. package/dist/virtualgpu.js +1000 -0
  216. package/dist/virtualization.d.ts +606 -0
  217. package/dist/virtualization.js +1186 -0
  218. package/dist/virtualmemory.d.ts +817 -0
  219. package/dist/virtualmemory.js +1795 -0
  220. package/dist/vite.config.d.ts +3 -0
  221. package/dist/vite.config.js +43 -0
  222. package/dist/vitest.config.d.ts +2 -0
  223. package/dist/vitest.config.js +25 -0
  224. package/dist/webserver.d.ts +166 -0
  225. package/dist/webserver.js +2226 -0
  226. package/dist/workloadlaw.d.ts +133 -0
  227. package/dist/workloadlaw.js +193 -0
  228. package/docs/artifactavailability.md +2 -2
  229. package/docs/{containerplatforms-2.1.0.md → containerplatforms.2.1.0.md} +1 -1
  230. package/docs/{documentmap-1.8.12.md → documentmap.1.8.12.md} +1 -1
  231. package/docs/ninety.modalities.md +108 -0
  232. package/docs/planning.ninety.md +391 -0
  233. package/docs/{releasenotes-1.8.13.md → releasenotes.1.8.13.md} +2 -2
  234. package/docs/{releasenotes-1.8.14.md → releasenotes.1.8.14.md} +1 -1
  235. package/docs/{repo-synthesis-1.8.16.md → repo.synthesis.1.8.16.md} +1 -1
  236. package/docs/{todo-1.8.15.md → todo.1.8.15.md} +1 -1
  237. package/docs/{todo-1.8.16.md → todo.1.8.16.md} +5 -5
  238. package/docs/todo.md +2 -2
  239. package/docs/workloadlaw.md +104 -0
  240. package/index.ts +1 -1
  241. package/manifest.json +1 -1
  242. package/modalityregistry.ts +1371 -34
  243. package/orchestrator.ts +1 -1
  244. package/package.json +73 -33
  245. package/docs/CONVERSA.txt +0 -1523
  246. package/docs/architecture-1.8.19.png +0 -0
  247. package/docs/assets/architecture.svg +0 -45
  248. package/docs/assets/saddle-browser-trace.webp +0 -0
  249. package/docs/assets/saddle-hero-bridge.webp +0 -0
  250. package/docs/assets/saddle-mark.webp +0 -0
  251. package/docs/assets/saddle-runtime-map.webp +0 -0
  252. package/docs/assets/saddlemark.svg +0 -13
  253. package/docs/saddle.archive.1.8.17.tar.gz.gpg +0 -0
  254. /package/docs/{architecture-1.8.19.md → architecture.1.8.19.md} +0 -0
  255. /package/docs/{architecture-1.8.19.mmd → architecture.1.8.19.mmd} +0 -0
  256. /package/docs/{artifactresearch-1.8.9.md → artifactresearch.1.8.9.md} +0 -0
  257. /package/docs/{batches-1816-1820.md → batches.1816.to.1820.md} +0 -0
  258. /package/docs/{brancharchive-2026-08-18.md → brancharchive.2026.08.18.md} +0 -0
  259. /package/docs/{containerplatforms-1.8.17.md → containerplatforms.1.8.17.md} +0 -0
  260. /package/docs/{e2ugh-engine.md → e2ugh.engine.md} +0 -0
  261. /package/docs/{flatbuildresearch-1.8.11.md → flatbuildresearch.1.8.11.md} +0 -0
  262. /package/docs/{licenseaudit-1.8.12.md → licenseaudit.1.8.12.md} +0 -0
  263. /package/docs/{mobileconversionresearch-1.8.10.md → mobileconversionresearch.1.8.10.md} +0 -0
  264. /package/docs/{native-wrappers.md → native.wrappers.md} +0 -0
  265. /package/docs/{nativeidentityresearch-1.8.11.md → nativeidentityresearch.1.8.11.md} +0 -0
  266. /package/docs/{releasenotes-1.8.12.md → releasenotes.1.8.12.md} +0 -0
  267. /package/docs/{releasenotes-1.8.15.md → releasenotes.1.8.15.md} +0 -0
  268. /package/docs/{releasenotes-1.8.16.md → releasenotes.1.8.16.md} +0 -0
  269. /package/docs/{releasenotes-1.8.17.md → releasenotes.1.8.17.md} +0 -0
  270. /package/docs/{releasenotes-1.8.18.md → releasenotes.1.8.18.md} +0 -0
  271. /package/docs/{releasenotes-1.8.19.md → releasenotes.1.8.19.md} +0 -0
  272. /package/docs/{reorganization-1.8.8.md → reorganization.1.8.8.md} +0 -0
  273. /package/docs/{reorganization-1.8.9.md → reorganization.1.8.9.md} +0 -0
  274. /package/docs/{repo-analysis-1.8.21.json → repo.analysis.1.8.21.json} +0 -0
  275. /package/docs/{repo-candidates-1.8.21.jsonl → repo.candidates.1.8.21.jsonl} +0 -0
  276. /package/docs/{repo-research-1.8.16.md → repo.research.1.8.16.md} +0 -0
  277. /package/docs/{repo-research-1.8.21.md → repo.research.1.8.21.md} +0 -0
  278. /package/docs/{repo-selected-1.8.21.json → repo.selected.1.8.21.json} +0 -0
  279. /package/docs/{repo-synthesis-1.8.21.md → repo.synthesis.1.8.21.md} +0 -0
  280. /package/docs/{research-1.8.18-isolation.md → research.1.8.18.isolation.md} +0 -0
  281. /package/docs/{research-1.8.19-virtual-browser.md → research.1.8.19.virtual.browser.md} +0 -0
  282. /package/docs/{scanningresearch-1.8.11.md → scanningresearch.1.8.11.md} +0 -0
  283. /package/docs/{securityaudit-1.8.7.md → securityaudit.1.8.7.md} +0 -0
  284. /package/docs/{securityreferences-1.8.11.md → securityreferences.1.8.11.md} +0 -0
  285. /package/docs/{securityresearch-1.8.11.md → securityresearch.1.8.11.md} +0 -0
  286. /package/docs/{signingresearch-1.8.12.md → signingresearch.1.8.12.md} +0 -0
  287. /package/docs/{tauri-glib-risk-1.8.11.md → tauri.glib.risk.1.8.11.md} +0 -0
  288. /package/docs/{todo-1.8.18.md → todo.1.8.18.md} +0 -0
  289. /package/docs/{todo-1.8.19.md → todo.1.8.19.md} +0 -0
  290. /package/docs/{toolchainresearch-1.8.9.md → toolchainresearch.1.8.9.md} +0 -0
  291. /package/docs/{web-duplicatecleanup.md → web.duplicatecleanup.md} +0 -0
  292. /package/docs/{web-ideas.md → web.ideas.md} +0 -0
  293. /package/docs/{workflowimprovements-2026-08-19.md → workflowimprovements.2026.08.19.md} +0 -0
@@ -0,0 +1,2226 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * webserver.ts — self-hosted node api for the saddle web console (the merged e2ugh sandbox surface, v7-BACK).
4
+ *
5
+ * pure node:http, zero dependencies, esm. the server serves the react
6
+ * spa build (the hashed bundles vite emits straight at the app root,
7
+ * produced once by "npm run web:build";
8
+ * content types parsed from web/mime.types at boot) with the index.html
9
+ * fallback for client routes, and it exposes the /api/v1 contract:
10
+ * health, spec catalogs read from the repository json files, in-memory
11
+ * sandboxes with the created ->
12
+ * running state machine persisted to sqlite, exec through the very
13
+ * same browser-pure dispatcher (sandbox.ts) with the persistent
14
+ * per-sandbox workspace filesystem (sandboxfiles table, quota capped,
15
+ * data stays with the sandbox id across restarts), the auth surface
16
+ * (register/login/logout/me backed by scrypt and sessions in db.ts),
17
+ * the edge execution plane (the modality catalog census, the probed
18
+ * lane report and the server-side edge runs riding the modality
19
+ * lanes), the signed mesh surface (register/heartbeat/nodes verified by
20
+ * mesh.ts), the admin surface (overview/users/nodes/sandboxes/audit)
21
+ * and the events poll for the dashboard. per the project rules there
22
+ * is no serverless function anywhere: this file is the whole backend
23
+ * and runs on any plain node host (docker, vps, caddy reverse proxy).
24
+ * the backend sources (server.ts, db.ts, auth.ts, mesh.ts, sandbox.ts)
25
+ * stay at the app root beside the served root, so the extension denylist
26
+ * keeps them unreachable by the static resolver.
27
+ *
28
+ * contexts (28): types, httpserver, portselection, noderole,
29
+ * staticfiles, mimetypes, contenttypes, cacheheaders, securityheaders,
30
+ * cors, jsonio, apierrors, health, speccatalogs, specscache, edgeplane,
31
+ * authroutes, sandboxes, sandboxfs, statemachine, ttlreaper,
32
+ * execendpoint, filesroutes, meshroutes, adminroutes, eventsroute,
33
+ * requestlogging, gracefulshutdown.
34
+ *
35
+ * rules: lowercase identifiers, english jsdoc in third person, no emoji,
36
+ * try/catch on every fallible path, standardized {error:{code,message}}
37
+ * json failures, no hardcoded localhost address, host and port resolved
38
+ * once at boot from argv/env with a random 30000-59999 default.
39
+ */
40
+ import { createServer } from 'node:http';
41
+ import { accessSync, createReadStream, readFileSync } from 'node:fs';
42
+ import { readFile } from 'node:fs/promises';
43
+ import { randomUUID } from 'node:crypto';
44
+ import { extname, join, normalize, dirname } from 'node:path';
45
+ import { fileURLToPath } from 'node:url';
46
+ import process from 'node:process';
47
+ import { createSandboxState, dispatch, cpudata, gpudata, migprofiles, getcpu, getcpubyid, getgpu, getmig, } from './sandbox.ts';
48
+ import { resolvesandboxspec } from './sandboxprofile.ts';
49
+ import { specenvlimits } from './sandboxenvprofile.ts';
50
+ import { accountlimitsfromenv, createaccountledger, quotaerror, usagefromrows, } from './sandboxquota.ts';
51
+ import store from './db.ts';
52
+ import { burndummy, cachesession, cookieclear, cookiefor, createsession, destroysession, extracttoken, hashpassword, ratelimit, ratelimits, requireauth, bootstraprole, seedadmins, validatepassword, validateusername, verifypassword, } from './auth.ts';
53
+ import { forwardauth, mainurl, meshsecret, role, startheartbeat, verifymesh } from './mesh.ts';
54
+ import { MODALITIES, MODALITYPLANCOUNT, planreport } from './modalityregistry.ts';
55
+ import { edgelandesreport, runonedge } from './edgeplane.ts';
56
+ import { CATALOGAPPS, familyjoin, workloadreport } from './workloadlaw.ts';
57
+ import { tmpdir } from 'node:os';
58
+ /** the directory holding this module: the repository root with the backend
59
+ * sources (db.ts, auth.ts, mesh.ts, sandbox.ts) after the Sol
60
+ * restructure (the theme lives in Sol/, the engine stays at the root). */
61
+ const moduledir = dirname(fileURLToPath(import.meta.url));
62
+ /** repository root resolved from this module: the same directory since
63
+ * the backend sources now sit at the repository root (the catalogs,
64
+ * processors.json, gpus.json, cores.json and boards.json, live here). */
65
+ const rootdir = moduledir;
66
+ /** the spa directory holding the built static assets: vite writes the
67
+ * hashed react bundle straight at the app root (the theme build), and
68
+ * the backend sources never live inside a separated build folder, so
69
+ * the extension denylist below keeps them unreachable. */
70
+ const webdir = moduledir;
71
+ /** api version tag reported by /api/v1/health; the release workflow
72
+ * greps this exact literal out of the source with a regular expression,
73
+ * so the declaration below stays a plain single-quoted string with no
74
+ * type annotation in typescript. */
75
+ const version = '2.0.152';
76
+ /** sandbox ttl in milliseconds (15 minutes) and sweep interval (60 s). */
77
+ const ttlms = 15 * 60 * 1000;
78
+ const sweepperiodms = 60 * 1000;
79
+ /** expired session sweep period for the main role (10 minutes). */
80
+ const sessionsweepperiodms = 10 * 60 * 1000;
81
+ /** firecracker-style bring-up delay before a sandbox reaches running. */
82
+ const startrampms = 125;
83
+ /** per-account resource ledger: the limits read once from the
84
+ * environment (SADDLE_MAX_SANDBOXES plus the SADDLE_ACCOUNT_* family)
85
+ * and stay unlimited when unset, so the default deployment enforces
86
+ * nothing. counters reconcile from the sqlite rows on every create and
87
+ * releases ride destroysandbox (sandboxquota.ts). */
88
+ const accountledger = createaccountledger(accountlimitsfromenv(process.env));
89
+ /** the session ttl mirrored from auth.ts for clone-forwarded sessions. */
90
+ const sessionttlms = 24 * 60 * 60 * 1000;
91
+ /* ------------------------------------------------------------------ */
92
+ /* context: portselection */
93
+ /* ------------------------------------------------------------------ */
94
+ /**
95
+ * resolves the listen port once at boot: --port argument wins, then the
96
+ * port or saddle_port environment variables, then a random port in the
97
+ * documented 30000-59999 range.
98
+ *
99
+ * @returns the tcp port to bind.
100
+ */
101
+ function resolveport() {
102
+ try {
103
+ const argindex = process.argv.indexOf('--port');
104
+ if (argindex !== -1 && process.argv[argindex + 1] !== undefined) {
105
+ const parsed = Number.parseInt(process.argv[argindex + 1], 10);
106
+ if (Number.isInteger(parsed) && parsed > 0 && parsed < 65536) {
107
+ return parsed;
108
+ }
109
+ }
110
+ const envport = Number.parseInt(process.env.PORT ?? process.env.SADDLE_PORT ?? '', 10);
111
+ if (Number.isInteger(envport) && envport > 0 && envport < 65536) {
112
+ return envport;
113
+ }
114
+ return 30000 + Math.floor(Math.random() * 30000);
115
+ }
116
+ catch {
117
+ return 30000 + Math.floor(Math.random() * 30000);
118
+ }
119
+ }
120
+ /** listen host: 0.0.0.0 unless saddle_host overrides; never localhost. */
121
+ const host = process.env.SADDLE_HOST && process.env.SADDLE_HOST.length > 0
122
+ ? process.env.SADDLE_HOST
123
+ : '0.0.0.0';
124
+ const port = resolveport();
125
+ /* ------------------------------------------------------------------ */
126
+ /* context: mimetypes and contenttypes */
127
+ /* ------------------------------------------------------------------ */
128
+ /**
129
+ * parses one debian mime.types document into an extension -> type map:
130
+ * blank lines and #-comments are skipped, the first column is the media
131
+ * type and the remaining columns are extensions. the first mapping wins
132
+ * so the canonical entry (text/javascript for es/js/mjs) is kept when
133
+ * an extension repeats.
134
+ *
135
+ * @param filepath the mime.types file location.
136
+ * @returns the extension (with dot) -> type map.
137
+ */
138
+ function parsemimetypes(filepath) {
139
+ const map = new Map();
140
+ try {
141
+ const content = readFileSync(filepath, 'utf8');
142
+ for (const rawline of content.split('\n')) {
143
+ const line = rawline.trim();
144
+ if (line.length === 0 || line.startsWith('#')) {
145
+ continue;
146
+ }
147
+ const fields = line.split(/\s+/);
148
+ const type = fields[0];
149
+ if (type === undefined || type.length === 0) {
150
+ continue;
151
+ }
152
+ for (const extension of fields.slice(1)) {
153
+ const key = `.${extension.toLowerCase()}`;
154
+ if (extension.length > 0 && !map.has(key)) {
155
+ map.set(key, type);
156
+ }
157
+ }
158
+ }
159
+ }
160
+ catch {
161
+ /* an unreadable mime.types leaves the map empty; the fallback
162
+ * application/octet-stream keeps files flowing */
163
+ }
164
+ return map;
165
+ }
166
+ /** the real extension -> media type table parsed once at boot (mime.types
167
+ * lives at the app root beside the theme entry, per the family template). */
168
+ const mimetable = parsemimetypes(join(moduledir, 'mime.types'));
169
+ /** the fallback type for extensions absent from the table. */
170
+ const fallbacktype = 'application/octet-stream';
171
+ /** extensions never served statically (database and log sidecar files). */
172
+ const denylist = new Set([
173
+ '.db',
174
+ '.sqlite',
175
+ '.sqlite3',
176
+ '.log',
177
+ '.ts',
178
+ '.tsx',
179
+ '.json',
180
+ '.prisma',
181
+ '.sql',
182
+ '.md',
183
+ '.toml',
184
+ '.yaml',
185
+ '.yml',
186
+ '.py',
187
+ '.rb',
188
+ '.cs',
189
+ '.java',
190
+ '.c',
191
+ '.cpp',
192
+ '.h',
193
+ '.hpp',
194
+ '.rs',
195
+ '.go',
196
+ '.lock',
197
+ ]);
198
+ /**
199
+ * resolves the content type for one lower-case extension.
200
+ *
201
+ * @param extension the extension including the dot.
202
+ * @returns the media type or the octet-stream fallback.
203
+ */
204
+ function contenttypefor(extension) {
205
+ return mimetable.get(extension) ?? fallbacktype;
206
+ }
207
+ /** per-extension cache policy: html revalidates, assets cache an hour. */
208
+ function cachefor(extension) {
209
+ if (extension === '.html' || extension === '.txt') {
210
+ return 'no-cache, must-revalidate';
211
+ }
212
+ return 'public, max-age=3600';
213
+ }
214
+ /* ------------------------------------------------------------------ */
215
+ /* context: securityheaders and cors */
216
+ /* ------------------------------------------------------------------ */
217
+ /**
218
+ * resolves the comma separated saddle_allowed_origins list once per
219
+ * request (cheap) so operators can change it without a restart tool.
220
+ *
221
+ * @returns the allowed origin list, possibly empty.
222
+ */
223
+ function allowedorigins() {
224
+ try {
225
+ return String(process.env.SADDLE_ALLOWED_ORIGINS ?? '')
226
+ .split(',')
227
+ .map((entry) => entry.trim())
228
+ .filter((entry) => entry.length > 0);
229
+ }
230
+ catch {
231
+ return [];
232
+ }
233
+ }
234
+ /**
235
+ * computes the security header set applied to every response: the
236
+ * locked-down content security policy (connect-src gains the main url
237
+ * on clone nodes), nosniff, DENY framing, the strict referrer policy,
238
+ * the empty permissions policy and HSTS on https requests.
239
+ *
240
+ * @param req the incoming request.
241
+ * @returns the header map.
242
+ */
243
+ function securityheaders(req) {
244
+ const forwarded = req?.headers?.['x-forwarded-proto'];
245
+ const ishttps = forwarded === 'https';
246
+ const connectextra = role === 'clone' && mainurl().length > 0 ? ` ${mainurl()}` : '';
247
+ const headers = {
248
+ 'content-security-policy': `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';` +
249
+ ` connect-src 'self'${connectextra}; img-src 'self'; object-src 'none';` +
250
+ ` base-uri 'self'; form-action 'self'; frame-ancestors 'none'`,
251
+ 'x-content-type-options': 'nosniff',
252
+ 'x-frame-options': 'DENY',
253
+ 'referrer-policy': 'strict-origin-when-cross-origin',
254
+ 'permissions-policy': 'camera=(), microphone=()',
255
+ };
256
+ if (ishttps) {
257
+ headers['strict-transport-security'] = 'max-age=31536000; includeSubDomains';
258
+ }
259
+ return headers;
260
+ }
261
+ /**
262
+ * computes the cors header set: public mode answers access-control-
263
+ * allow-origin * (health and the spec catalogs), credentials mode
264
+ * echoes the origin and allows credentials only for origins listed in
265
+ * saddle_allowed_origins.
266
+ *
267
+ * @param req the incoming request.
268
+ * @param mode the cors mode.
269
+ * @returns the header map.
270
+ */
271
+ function corsheaders(req, mode) {
272
+ if (mode === 'credentials') {
273
+ const origin = req?.headers?.origin;
274
+ const headers = {
275
+ 'access-control-allow-methods': 'GET, POST, DELETE, OPTIONS',
276
+ 'access-control-allow-headers': 'content-type, authorization, x-saddle-timestamp, x-saddle-signature',
277
+ vary: 'Origin',
278
+ };
279
+ if (typeof origin === 'string' && allowedorigins().includes(origin)) {
280
+ headers['access-control-allow-origin'] = origin;
281
+ headers['access-control-allow-credentials'] = 'true';
282
+ }
283
+ return headers;
284
+ }
285
+ return {
286
+ 'access-control-allow-origin': '*',
287
+ 'access-control-allow-methods': 'GET, POST, DELETE, OPTIONS',
288
+ 'access-control-allow-headers': 'content-type',
289
+ };
290
+ }
291
+ /**
292
+ * merges the security and cors header sets for one request.
293
+ *
294
+ * @param req the incoming request.
295
+ * @param mode the cors mode (defaults to
296
+ * credentials for the authed surface).
297
+ * @returns the merged header map.
298
+ */
299
+ function respondheaders(req, mode = 'credentials') {
300
+ return { ...securityheaders(req), ...corsheaders(req, mode) };
301
+ }
302
+ /* ------------------------------------------------------------------ */
303
+ /* context: jsonio and apierrors */
304
+ /* ------------------------------------------------------------------ */
305
+ /**
306
+ * writes a json response with the given header set (security + cors
307
+ * computed by the caller plus optional extras like set-cookie and
308
+ * retry-after).
309
+ *
310
+ * @param res the outgoing response.
311
+ * @param status http status code.
312
+ * @param payload json-serializable payload.
313
+ * @param headers the extra headers.
314
+ */
315
+ function writejson(res, status, payload, headers = {}) {
316
+ try {
317
+ const body = JSON.stringify(payload);
318
+ res.writeHead(status, {
319
+ 'content-type': 'application/json; charset=utf-8',
320
+ 'content-length': Buffer.byteLength(body),
321
+ 'cache-control': 'no-store',
322
+ ...headers,
323
+ });
324
+ res.end(body);
325
+ }
326
+ catch {
327
+ res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' });
328
+ res.end(JSON.stringify({ error: { code: 'internal', message: 'response serialization failed' } }));
329
+ }
330
+ }
331
+ /**
332
+ * extracts a safe, stack-trace-free message from a thrown value.
333
+ * `String(error)` on an Error object includes the full stack trace, which
334
+ * must never reach a client response; this helper returns only the message
335
+ * property (no stack) or a generic fallback for non-error throws.
336
+ */
337
+ function safemsg(error) {
338
+ // log internal technical details server-side only; the returned string is a
339
+ // generic, stack-trace-free message so no tainted information reaches
340
+ // client responses (CodeQL js/stack-trace-exposure). errors thrown from the
341
+ // data layer carry a non-enumerable `publicMessage` (static, hardcoded per
342
+ // code) which is the only safe, untainted string returned to the network.
343
+ // the underlying driver error is logged with its stack to stderr.
344
+ if (error instanceof Error) {
345
+ const internal = error.internal;
346
+ console.error(`[server] ${error.message}${internal ? ` :: ${internal}` : ''}\n${error.stack ?? ''}`);
347
+ const publicmsg = error.publicMessage;
348
+ if (typeof publicmsg === 'string' && publicmsg.length > 0) {
349
+ return publicmsg;
350
+ }
351
+ }
352
+ else if (typeof error === 'string' && error.length > 0) {
353
+ console.error(`[server] non-error throw: ${error}`);
354
+ }
355
+ return 'unexpected error';
356
+ }
357
+ /**
358
+ * writes the standardized error payload {error:{code,message}}.
359
+ *
360
+ * @param res the outgoing response.
361
+ * @param status http status code.
362
+ * @param code machine readable error code.
363
+ * @param message human readable explanation.
364
+ * @param headers the extra headers.
365
+ */
366
+ function writeerror(res, status, code, message, headers = {}) {
367
+ // for server errors (5xx) never expose internal details or stack traces
368
+ // to the client; the code is machine-readable, the message becomes generic.
369
+ const safeMessage = status >= 500
370
+ ? 'an internal error occurred; please try again later'
371
+ : String(message ?? '');
372
+ writejson(res, status, { error: { code, message: safeMessage } }, headers);
373
+ }
374
+ /**
375
+ * extracts the client ip for audit rows and rate limit buckets.
376
+ *
377
+ * @param req the incoming request.
378
+ * @returns the remote address or unknown.
379
+ */
380
+ function clientip(req) {
381
+ try {
382
+ return req.socket?.remoteAddress ?? 'unknown';
383
+ }
384
+ catch {
385
+ return 'unknown';
386
+ }
387
+ }
388
+ /**
389
+ * extracts the user agent header bounded to 256 characters.
390
+ *
391
+ * @param req the incoming request.
392
+ * @returns the trimmed user agent.
393
+ */
394
+ function clientuseragent(req) {
395
+ try {
396
+ const value = req.headers?.['user-agent'];
397
+ return typeof value === 'string' ? value.slice(0, 256) : undefined;
398
+ }
399
+ catch {
400
+ return undefined;
401
+ }
402
+ }
403
+ /* ------------------------------------------------------------------ */
404
+ /* context: speccatalogs and specscache */
405
+ /* ------------------------------------------------------------------ */
406
+ /** catalog map: /api/v1/specs/<key> reads the mapped repository file. */
407
+ const speccatalogs = {
408
+ cpus: 'processors.json',
409
+ gpus: 'gpus.json',
410
+ memory: 'cores.json',
411
+ };
412
+ /** memoization cache: catalog key to parsed json document. */
413
+ const specscache = new Map();
414
+ /**
415
+ * reads and caches one spec catalog; files are parsed once per process
416
+ * lifetime and served from the map afterwards.
417
+ *
418
+ * @param key one of cpus, gpus, memory.
419
+ * @returns the parsed json document.
420
+ */
421
+ async function getspeccatalog(key) {
422
+ if (specscache.has(key)) {
423
+ return specscache.get(key);
424
+ }
425
+ const filename = speccatalogs[key];
426
+ if (filename === undefined) {
427
+ throw new Error(`unknown spec catalog "${key}"; valid catalogs: ${Object.keys(speccatalogs).join(', ')}`);
428
+ }
429
+ const document = JSON.parse(await readFile(join(rootdir, filename), 'utf8'));
430
+ specscache.set(key, document);
431
+ return document;
432
+ }
433
+ /* ------------------------------------------------------------------ */
434
+ /* context: sandboxfs — the persistent workspace bindings */
435
+ /* ------------------------------------------------------------------ */
436
+ /**
437
+ * resolves whether expired sandbox workspaces are explicitly retained
438
+ * (saddle_sandbox_persist=true); the default behavior already keeps the
439
+ * files because the sandbox is self-contained, the flag only records
440
+ * the operator's intent in the expiry event.
441
+ *
442
+ * @returns true when the retention flag is set.
443
+ */
444
+ function workspacepersist() {
445
+ try {
446
+ return String(process.env.SADDLE_SANDBOX_PERSIST ?? '') === 'true';
447
+ }
448
+ catch {
449
+ return false;
450
+ }
451
+ }
452
+ /**
453
+ * builds the dispatcher filesystem context of one sandbox id: the four
454
+ * callbacks bound to the sqlite workspace table with the quota check
455
+ * inside write and defensive degradation on the read paths. a fresh id
456
+ * always starts empty (new uuid, no seeding); the files written through
457
+ * this context live in the database file of the node, so they survive
458
+ * process restarts and stay addressable by the sandbox id.
459
+ *
460
+ * @param id the sandbox id.
461
+ * @param quotabytes the optional per-sandbox quota picked at creation.
462
+ * @returns the dispatcher context.
463
+ */
464
+ function sandboxfscontext(id, quotabytes) {
465
+ return {
466
+ quota: quotabytes ?? store.sandboxquota(),
467
+ fs: {
468
+ write(path, content) {
469
+ return store.writefile(id, path, content, quotabytes);
470
+ },
471
+ read(path) {
472
+ try {
473
+ return store.readfile(id, path);
474
+ }
475
+ catch {
476
+ return null;
477
+ }
478
+ },
479
+ list() {
480
+ try {
481
+ return store.listfiles(id);
482
+ }
483
+ catch {
484
+ return [];
485
+ }
486
+ },
487
+ del(path) {
488
+ try {
489
+ return store.deletefile(id, path);
490
+ }
491
+ catch {
492
+ return false;
493
+ }
494
+ },
495
+ },
496
+ };
497
+ }
498
+ /**
499
+ * refreshes the usage counters of one sandbox in memory and in the
500
+ * sandboxes row; failures never break the exec response.
501
+ *
502
+ * @param record the stored sandbox record.
503
+ * @returns void.
504
+ */
505
+ function refreshusage(record) {
506
+ try {
507
+ const usage = store.sandboxusage(record.id);
508
+ record.usage = usage;
509
+ store.updatesandboxusage(record.id, usage.files, usage.bytes);
510
+ }
511
+ catch {
512
+ /* usage bookkeeping is best effort */
513
+ }
514
+ }
515
+ /* ------------------------------------------------------------------ */
516
+ /* context: sandboxes, statemachine and ttlreaper */
517
+ /* ------------------------------------------------------------------ */
518
+ /**
519
+ * in-memory sandbox store: id -> record. the record carries the api
520
+ * lifecycle fields plus the private engine state consumed by dispatch
521
+ * and the owning userid persisted in the sandboxes table.
522
+ */
523
+ const sandboxes = new Map();
524
+ /**
525
+ * creates a sandbox record: id, the created -> running state machine with
526
+ * the 125 ms firecracker ramp, the resolved spec, the owning user and
527
+ * the dispatcher state built by createsandboxstate. the row is mirrored
528
+ * into the sqlite sandboxes table. malformed requests throw errors
529
+ * carrying status and code fields consumed by the route handler.
530
+ *
531
+ * @param body the creation request; the cpu
532
+ * model comes from the reviewed catalog, quotamb picks the persistent
533
+ * workspace quota (4-256 MiB).
534
+ * @param user the authenticated owner.
535
+ * @returns the stored sandbox record.
536
+ */
537
+ function createsandbox(body, user) {
538
+ // no per-user sandbox cap by design: the project is open source and the
539
+ // shelf grows with the account; the workspace quota per sandbox already
540
+ // bounds the database, and operators who want a cap set SADDLE_MAX_SANDBOXES
541
+ // (0 or unset means unlimited - the default).
542
+ const configuredcap = Math.max(0, Number(process.env.SADDLE_MAX_SANDBOXES ?? '0') || 0);
543
+ if (configuredcap > 0) {
544
+ const mine = store.listsandboxesbyuser(user.id, 1000).filter((row) => row.state !== 'destroyed');
545
+ if (mine.length >= configuredcap) {
546
+ throw Object.assign(new Error(`sandbox limit reached for this account (${configuredcap}); delete one to create another`), { code: 'sandbox-limit', status: 429 });
547
+ }
548
+ }
549
+ // declarative spec plane: when any declaration field is present the
550
+ // request resolves through sandboxprofile.ts (base, os, arch, form,
551
+ // vgpu, diskgb, timeout, networking) against the SADDLE_SPEC_LIMITS
552
+ // table and the resolved values feed the engine spec below; legacy
553
+ // bodies keep the exact previous behavior.
554
+ const specactive = body.base !== undefined ||
555
+ body.os !== undefined ||
556
+ body.arch !== undefined ||
557
+ body.form !== undefined ||
558
+ body.networking !== undefined ||
559
+ body.vgpu !== undefined ||
560
+ body.diskgb !== undefined ||
561
+ body.timeoutseconds !== undefined;
562
+ let specresolved = null;
563
+ if (specactive) {
564
+ const verdict = resolvesandboxspec({
565
+ base: body.base ?? 'balanced',
566
+ os: body.os,
567
+ arch: body.arch,
568
+ form: body.form,
569
+ vcpus: body.vcpus,
570
+ ramgb: body.ramgb,
571
+ vgpu: body.vgpu,
572
+ diskgb: body.diskgb,
573
+ timeoutseconds: body.timeoutseconds,
574
+ networking: body.networking,
575
+ }, specenvlimits(process.env));
576
+ if (!verdict.ok) {
577
+ throw Object.assign(new Error(verdict.error.message), {
578
+ code: verdict.error.code,
579
+ status: 400,
580
+ });
581
+ }
582
+ specresolved = verdict.value;
583
+ }
584
+ const requestedvcpus = specresolved === null ? body.vcpus : specresolved.vcpus;
585
+ const requestedramgb = specresolved === null ? body.ramgb : specresolved.ramgb;
586
+ // user chosen persistent workspace quota, 4-256 MiB, capped by the node
587
+ // maximum inside db.writefile; omitted falls back to the node default.
588
+ let quotabytes;
589
+ if (body.quotamb !== undefined) {
590
+ const mb = Number(body.quotamb);
591
+ if (!Number.isFinite(mb) || mb < 4 || mb > 256) {
592
+ throw Object.assign(new Error('quotamb must be between 4 and 256'), {
593
+ code: 'invalid-quota',
594
+ status: 400,
595
+ });
596
+ }
597
+ quotabytes = Math.round(mb * 1024 * 1024);
598
+ }
599
+ // the reviewed catalog is the only source of processor identity: users
600
+ // pick any listed model; brand new processors join through a pull
601
+ // request (readme section), never through runtime free-form input.
602
+ // catalog lookup accepts both the display name (AMD EPYC 9965) and the
603
+ // catalog id (epyc-9965) so /specs/cpus consumers can echo ids back.
604
+ const cpu = getcpu(body.model ?? cpudata[0].model) ?? getcpubyid(body.model ?? '');
605
+ if (cpu === undefined) {
606
+ throw Object.assign(new Error(`unknown cpu model "${body.model}"; valid models: ${cpudata.map((entry) => entry.model).join(', ')} (new processors join the catalog by pull request)`), { code: 'unknown-model', status: 400 });
607
+ }
608
+ const gpu = getgpu(body.gpu ?? gpudata[0].id);
609
+ if (gpu === undefined) {
610
+ throw Object.assign(new Error(`unknown gpu "${body.gpu}"; valid gpus: ${gpudata.map((entry) => entry.id).join(', ')}`), { code: 'unknown-gpu', status: 400 });
611
+ }
612
+ // vcpu ceiling follows the chosen catalog identity (its own thread
613
+ // count); the web caller picks any model and any topology up to it.
614
+ const vcpumax = cpu.threads;
615
+ const vcpus = requestedvcpus === undefined ? 8 : Number(requestedvcpus);
616
+ if (!Number.isInteger(vcpus) || vcpus < 1 || vcpus > vcpumax) {
617
+ throw Object.assign(new Error(`vcpus must be an integer between 1 and ${vcpumax}`), { code: 'invalid-vcpus', status: 400 });
618
+ }
619
+ if (vcpus > cpu.threads) {
620
+ throw Object.assign(new Error(`model ${cpu.model} exposes at most ${cpu.threads} threads; ${vcpus} were requested`), { code: 'invalid-vcpus', status: 400 });
621
+ }
622
+ // user defined virtual memory: the identity accepts any plan from 1 gb
623
+ // up to 18 tb (the spoofing layer reports exactly this number; real
624
+ // execution is bounded by the host overcommit policy as documented in
625
+ // web/readme.md bottleneck analysis).
626
+ const ramgb = requestedramgb === undefined ? 32 : Number(requestedramgb);
627
+ if (!Number.isFinite(ramgb) || ramgb < 1 || ramgb > 18432) {
628
+ throw Object.assign(new Error('ramgb must be between 1 and 18432'), {
629
+ code: 'invalid-ram',
630
+ status: 400,
631
+ });
632
+ }
633
+ const mig = body.mig === undefined || body.mig === '' ? 'off' : String(body.mig);
634
+ if (mig !== 'off' && getmig(mig) === null) {
635
+ throw Object.assign(new Error(`unknown mig profile "${mig}"; valid profiles: ${migprofiles.map((entry) => entry.id).join(', ')} or off`), { code: 'invalid-mig', status: 400 });
636
+ }
637
+ // per-account resource ledger: reconcile the counters from the
638
+ // authoritative sqlite rows (the simple reconciliation of
639
+ // sandboxquota.ts) then reserve the new sandbox; a plan limit maps
640
+ // to 429 like the sandbox count cap above.
641
+ try {
642
+ accountledger.reconcile(user.id, usagefromrows(store.listsandboxesbyuser(user.id, 1000)));
643
+ }
644
+ catch {
645
+ /* the ledger keeps its last known counters until the next create */
646
+ }
647
+ try {
648
+ accountledger.reserve(user.id, { sandboxes: 1, vcpus, ramgb });
649
+ }
650
+ catch (error) {
651
+ if (error instanceof quotaerror && error.code === 'quota-exceeded') {
652
+ throw Object.assign(new Error(error.message), { code: 'account-quota', status: 429 });
653
+ }
654
+ throw error;
655
+ }
656
+ const id = randomUUID();
657
+ const engine = createSandboxState({ model: cpu.model, vcpus, ramgb, gpu: gpu.id, mig, id });
658
+ const now = Date.now();
659
+ // the declared timeout clamps to the platform ttl; without a declared
660
+ // timeout the 15 minutes ttl applies unchanged.
661
+ const expiresat = specresolved === null ? now + ttlms : now + Math.min(ttlms, specresolved.timeoutseconds * 1000);
662
+ const record = {
663
+ id,
664
+ userid: user.id,
665
+ state: 'created',
666
+ spec: {
667
+ model: cpu.model,
668
+ vcpus,
669
+ ramgb,
670
+ gpu: gpu.id,
671
+ mig,
672
+ os: specresolved?.os,
673
+ arch: specresolved?.arch,
674
+ form: specresolved?.form,
675
+ networking: specresolved?.networking,
676
+ vgpu: specresolved?.vgpu,
677
+ diskgb: specresolved?.diskgb,
678
+ timeoutseconds: specresolved?.timeoutseconds,
679
+ },
680
+ createdat: now,
681
+ startedat: null,
682
+ expiresat,
683
+ execcount: 0,
684
+ lastcommand: null,
685
+ usage: { files: 0, bytes: 0 },
686
+ engine,
687
+ fscontext: sandboxfscontext(id, quotabytes),
688
+ };
689
+ sandboxes.set(id, record);
690
+ store.createsandboxrecord({
691
+ id,
692
+ userid: user.id,
693
+ model: cpu.model,
694
+ vcpus,
695
+ ramgb,
696
+ gpu: gpu.id,
697
+ state: 'created',
698
+ createdat: new Date(now).toISOString(),
699
+ expiresat: new Date(now + ttlms).toISOString(),
700
+ quotabytes: quotabytes ?? null,
701
+ });
702
+ store.addevent({ topic: 'sandbox.created', payload: { id, userid: user.id, model: cpu.model } });
703
+ const ramp = setTimeout(() => {
704
+ try {
705
+ if (record.state === 'created') {
706
+ record.state = 'running';
707
+ record.startedat = Date.now();
708
+ }
709
+ }
710
+ catch {
711
+ /* state flip is best effort */
712
+ }
713
+ }, startrampms);
714
+ ramp.unref?.();
715
+ return record;
716
+ }
717
+ /**
718
+ * destroys one sandbox in memory and in the sqlite mirror, purging its
719
+ * workspace files with it (the manual delete is the purge path); the
720
+ * lifecycle event is emitted for the dashboard poll.
721
+ *
722
+ * @param record the stored sandbox record.
723
+ * @returns void.
724
+ */
725
+ function destroysandbox(record) {
726
+ record.state = 'destroyed';
727
+ sandboxes.delete(record.id);
728
+ try {
729
+ // the ledger releases what the create reserved; the reconcile on
730
+ // the next create stays the source of truth either way.
731
+ accountledger.release(record.userid, {
732
+ sandboxes: 1,
733
+ vcpus: record.spec.vcpus,
734
+ ramgb: record.spec.ramgb,
735
+ });
736
+ store.updatesandboxstate(record.id, 'destroyed');
737
+ store.deletesandboxfiles(record.id);
738
+ store.addevent({ topic: 'sandbox.destroyed', payload: { id: record.id } });
739
+ }
740
+ catch {
741
+ /* the memory state machine stays authoritative */
742
+ }
743
+ }
744
+ /**
745
+ * expires one sandbox past its ttl: the in-memory record drops and the
746
+ * row flips to expired, but the workspace files are kept by design so
747
+ * the container stays self-contained (the data lives with the sandbox
748
+ * id until a manual delete purges it).
749
+ *
750
+ * @param record the stored sandbox record.
751
+ * @returns void.
752
+ */
753
+ function expiresandbox(record) {
754
+ record.state = 'expired';
755
+ sandboxes.delete(record.id);
756
+ try {
757
+ store.updatesandboxstate(record.id, 'expired');
758
+ store.addevent({
759
+ topic: 'sandbox.expired',
760
+ payload: { id: record.id, fileskept: true, persist: workspacepersist() },
761
+ });
762
+ }
763
+ catch {
764
+ /* the memory state machine stays authoritative */
765
+ }
766
+ }
767
+ /**
768
+ * projects the public view of a sandbox record (the engine state stays
769
+ * private to the process).
770
+ *
771
+ * @param record the stored sandbox record.
772
+ * @returns the json-safe status document.
773
+ */
774
+ function publicview(record) {
775
+ return {
776
+ id: record.id,
777
+ state: record.state,
778
+ model: record.spec.model,
779
+ vcpus: record.spec.vcpus,
780
+ ramgb: record.spec.ramgb,
781
+ gpu: record.spec.gpu,
782
+ mig: record.spec.mig,
783
+ spec: record.spec,
784
+ createdAt: new Date(record.createdat).toISOString(),
785
+ startedAt: record.startedat === null ? null : new Date(record.startedat).toISOString(),
786
+ expiresAt: new Date(record.expiresat).toISOString(),
787
+ ttlSeconds: Math.max(0, Math.round((record.expiresat - Date.now()) / 1000)),
788
+ execCount: record.execcount,
789
+ lastCommand: record.lastcommand,
790
+ usage: record.usage ?? { files: 0, bytes: 0 },
791
+ };
792
+ }
793
+ /**
794
+ * ttl reaper: destroys sandboxes past their expiry every sweep period
795
+ * (mirroring the state into sqlite); the interval is unref'd so the
796
+ * process can exit cleanly.
797
+ *
798
+ * @returns the sweep timer.
799
+ */
800
+ function startreaper() {
801
+ const timer = setInterval(() => {
802
+ try {
803
+ const now = Date.now();
804
+ for (const [, record] of sandboxes) {
805
+ if (record.expiresat <= now) {
806
+ expiresandbox(record);
807
+ }
808
+ }
809
+ }
810
+ catch {
811
+ /* the sweep never crashes the server */
812
+ }
813
+ }, sweepperiodms);
814
+ timer.unref?.();
815
+ return timer;
816
+ }
817
+ /**
818
+ * resolves whether one user may touch one sandbox record: the owner or
819
+ * any admin.
820
+ *
821
+ * @param record the stored sandbox record.
822
+ * @param user the authenticated user.
823
+ * @returns the verdict.
824
+ */
825
+ function ownssandbox(record, user) {
826
+ return record.userid === user.id || user.role === 'admin';
827
+ }
828
+ /* ------------------------------------------------------------------ */
829
+ /* context: bodyreader */
830
+ /* ------------------------------------------------------------------ */
831
+ /**
832
+ * reads the raw request body bounded to 64 kb; mesh verification needs
833
+ * the exact bytes so the json parsing lives one layer above.
834
+ *
835
+ * @param req the incoming request.
836
+ * @returns the raw body, empty when absent.
837
+ */
838
+ function readrawbody(req) {
839
+ return new Promise((resolvebody, rejectbody) => {
840
+ try {
841
+ const chunks = [];
842
+ let size = 0;
843
+ req.on('data', (chunk) => {
844
+ size += chunk.length;
845
+ if (size > 65536) {
846
+ // deliver the 413 before tearing the socket down: pause the
847
+ // stream, remove the data listener and let the response writer
848
+ // flush first, so the client reads a real status line instead
849
+ // of a connection reset.
850
+ req.pause();
851
+ req.removeAllListeners('data');
852
+ req.removeAllListeners('end');
853
+ rejectbody(Object.assign(new Error('request body exceeds 64 kb'), {
854
+ code: 'payload-too-large',
855
+ status: 413,
856
+ }));
857
+ setImmediate(() => {
858
+ req.destroy();
859
+ });
860
+ return;
861
+ }
862
+ chunks.push(chunk);
863
+ });
864
+ req.on('end', () => resolvebody(Buffer.concat(chunks)));
865
+ req.on('error', (error) => rejectbody(error));
866
+ }
867
+ catch (error) {
868
+ rejectbody(error);
869
+ }
870
+ });
871
+ }
872
+ /**
873
+ * reads and parses a json request body bounded to 64 kb.
874
+ *
875
+ * @param req the incoming request.
876
+ * @returns the parsed body or {} when absent.
877
+ */
878
+ async function readbody(req) {
879
+ const raw = await readrawbody(req);
880
+ if (raw.length === 0) {
881
+ return {};
882
+ }
883
+ try {
884
+ return JSON.parse(raw.toString('utf8'));
885
+ }
886
+ catch {
887
+ throw Object.assign(new Error('request body is not valid json'), {
888
+ code: 'invalid-json',
889
+ status: 400,
890
+ });
891
+ }
892
+ }
893
+ /* ------------------------------------------------------------------ */
894
+ /* context: staticfiles */
895
+ /* ------------------------------------------------------------------ */
896
+ /**
897
+ * serves one static file from the spa build with traversal protection,
898
+ * streaming, the real mime.types content types, the shared security
899
+ * headers and the index.html fallback for client routes.
900
+ *
901
+ * @param urlpath the decoded url path.
902
+ * @param res the outgoing response.
903
+ * @param req the incoming request.
904
+ * @returns void.
905
+ */
906
+ /** backend sources are never served: the api modules
907
+ * (server.ts, db.ts, auth.ts, mesh.ts, sandbox.ts), the schema files
908
+ * (init.sql, schema.prisma, drizzle.config.ts) and the deploy manifests
909
+ * stay at the app root beside the built bundles, so the extension denylist
910
+ * below is the defense that keeps them unreachable (a
911
+ * stray .db/.sqlite/.log artifact inside a build stays covered too). */
912
+ function servestatic(urlpath, res, req) {
913
+ const headers = securityheaders(req);
914
+ try {
915
+ const relative = urlpath === '/' ? 'index.html' : urlpath.replace(/^\/+/, '');
916
+ // clean urls: the react router owns every route; the legacy page names
917
+ // (/login, /register, /dashboard) stay pinned to the spa shell so
918
+ // bookmarks and the login ?next= flow keep resolving, and every other
919
+ // extensionless path falls back to index.html below (the spa renders
920
+ // its own 404 for unknown routes).
921
+ const pagemap = {
922
+ login: 'index.html',
923
+ register: 'index.html',
924
+ dashboard: 'index.html',
925
+ };
926
+ const mapped = pagemap[relative] ?? relative;
927
+ const safepath = normalize(join(webdir, mapped));
928
+ if (!safepath.startsWith(webdir)) {
929
+ res.writeHead(403, {
930
+ 'content-type': 'text/plain; charset=utf-8',
931
+ ...headers,
932
+ });
933
+ res.end('forbidden');
934
+ return;
935
+ }
936
+ const extension = extname(safepath).toLowerCase();
937
+ if (denylist.has(extension)) {
938
+ res.writeHead(404, {
939
+ 'content-type': 'text/plain; charset=utf-8',
940
+ ...headers,
941
+ });
942
+ res.end('not found');
943
+ return;
944
+ }
945
+ // existence check before 200: a missing asset answers a real 404
946
+ // instead of a 200 with an error body (the stream error path stays
947
+ // as the race fallback).
948
+ let fileexists = true;
949
+ try {
950
+ accessSync(safepath);
951
+ }
952
+ catch {
953
+ fileexists = false;
954
+ }
955
+ if (!fileexists) {
956
+ // the real spa fallback: any extensionless client route (or the
957
+ // pagemap entries above) resolves to the shell so deep links and
958
+ // refreshes survive; the react router then owns the actual 404.
959
+ if (extension === '' || mapped === 'index.html') {
960
+ serveshell(res, headers);
961
+ return;
962
+ }
963
+ res.writeHead(404, {
964
+ 'content-type': 'text/plain; charset=utf-8',
965
+ ...headers,
966
+ });
967
+ res.end('not found');
968
+ return;
969
+ }
970
+ const type = contenttypefor(extension);
971
+ const stream = createReadStream(safepath);
972
+ stream.on('error', () => {
973
+ if (!res.headersSent) {
974
+ res.writeHead(404, {
975
+ 'content-type': 'text/plain; charset=utf-8',
976
+ ...headers,
977
+ });
978
+ }
979
+ res.end('not found');
980
+ });
981
+ res.writeHead(200, {
982
+ 'content-type': type,
983
+ 'cache-control': cachefor(extension),
984
+ ...headers,
985
+ });
986
+ stream.pipe(res);
987
+ }
988
+ catch {
989
+ res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8', ...headers });
990
+ res.end('internal server error');
991
+ }
992
+ }
993
+ /**
994
+ * serves the spa shell (the app root index.html) with the html cache
995
+ * policy; when the vite build is absent the operator gets the honest
996
+ * explanation instead of a bare 404 - the /api/v1 surface and this
997
+ * server keep working either way.
998
+ *
999
+ * @param res the outgoing response.
1000
+ * @param headers the prepared security headers.
1001
+ * @returns void.
1002
+ */
1003
+ function serveshell(res, headers) {
1004
+ const shellpath = join(webdir, 'index.html');
1005
+ try {
1006
+ accessSync(shellpath);
1007
+ }
1008
+ catch {
1009
+ res.writeHead(503, {
1010
+ 'content-type': 'text/plain; charset=utf-8',
1011
+ 'cache-control': 'no-store',
1012
+ ...headers,
1013
+ });
1014
+ res.end('the saddle interface build is missing: run "npm run web:build" once ' +
1015
+ '(vite writes the hashed bundles straight at the app root) and reload this page. the /api/v1/* ' +
1016
+ 'surface of this node is unaffected and keeps answering.');
1017
+ return;
1018
+ }
1019
+ const stream = createReadStream(shellpath);
1020
+ stream.on('error', () => {
1021
+ if (!res.headersSent) {
1022
+ res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8', ...headers });
1023
+ }
1024
+ res.end('the interface shell could not be read; run "npm run web:build" and reload.');
1025
+ });
1026
+ res.writeHead(200, {
1027
+ 'content-type': contenttypefor('.html'),
1028
+ 'cache-control': cachefor('.html'),
1029
+ ...headers,
1030
+ });
1031
+ stream.pipe(res);
1032
+ }
1033
+ /* ------------------------------------------------------------------ */
1034
+ /* context: authroutes */
1035
+ /* ------------------------------------------------------------------ */
1036
+ /**
1037
+ * handles the auth surface: register (first user of an empty node is
1038
+ * promoted to admin - the documented bootstrap), login with the generic
1039
+ * invalid credentials failure, logout and me. clone nodes forward
1040
+ * register and login to the main authority and cache the returned
1041
+ * session locally.
1042
+ *
1043
+ * @param req the incoming request.
1044
+ * @param res the outgoing response.
1045
+ * @param path the api-relative path.
1046
+ * @param segments the path segments.
1047
+ * @returns true when the request was handled.
1048
+ */
1049
+ async function handleauth(req, res, path, segments) {
1050
+ if (segments[0] !== 'auth') {
1051
+ return false;
1052
+ }
1053
+ const headers = respondheaders(req, 'credentials');
1054
+ const ip = clientip(req);
1055
+ const useragent = clientuseragent(req);
1056
+ /* POST /auth/register */
1057
+ if (req.method === 'POST' && path === '/auth/register') {
1058
+ const limit = ratelimit(`register:${ip}`, ratelimits.register.limit, ratelimits.register.windowms);
1059
+ if (!limit.allowed) {
1060
+ writeerror(res, 429, 'rate-limited', `too many registrations; retry in ${limit.retryafter} s`, {
1061
+ ...headers,
1062
+ 'retry-after': String(limit.retryafter),
1063
+ });
1064
+ return true;
1065
+ }
1066
+ try {
1067
+ const body = await readbody(req);
1068
+ if (body === null || typeof body !== 'object') {
1069
+ writeerror(res, 400, 'invalid-body', 'request body must be a json object', headers);
1070
+ return true;
1071
+ }
1072
+ const credentials = body;
1073
+ const usernamecheck = validateusername(credentials.username);
1074
+ if (!usernamecheck.ok) {
1075
+ writeerror(res, 400, usernamecheck.code, usernamecheck.message, headers);
1076
+ return true;
1077
+ }
1078
+ const passwordcheck = validatepassword(credentials.password);
1079
+ if (!passwordcheck.ok) {
1080
+ writeerror(res, 400, passwordcheck.code, passwordcheck.message, headers);
1081
+ return true;
1082
+ }
1083
+ if (role === 'clone' && mainurl().length > 0 && meshsecret().length > 0) {
1084
+ const forwarded = await forwardauth('register', body);
1085
+ await cacheforwarded(forwarded, req, headers, res);
1086
+ return true;
1087
+ }
1088
+ const existing = store.finduserbyname(String(credentials.username));
1089
+ if (existing !== null) {
1090
+ writeerror(res, 409, 'username-taken', 'username is already registered', headers);
1091
+ return true;
1092
+ }
1093
+ const { passwordhash, salt } = hashpassword(String(credentials.password));
1094
+ const userrole = bootstraprole(String(credentials.username));
1095
+ const user = store.createuser({
1096
+ username: String(credentials.username),
1097
+ passwordhash,
1098
+ salt,
1099
+ role: userrole,
1100
+ });
1101
+ const session = createsession(user.id, { ip, useragent });
1102
+ store.addaudit({ userid: user.id, action: 'register', detail: `role ${userrole}`, ip });
1103
+ store.addevent({ topic: 'auth.register', payload: { userid: user.id, username: user.username } });
1104
+ writejson(res, 201, { user: { id: user.id, username: user.username, role: user.role, createdAt: user.createdat } }, { ...headers, 'set-cookie': cookiefor(session.token, req) });
1105
+ }
1106
+ catch (error) {
1107
+ writeerror(res, error?.status ?? 500, error?.code ?? 'register-failed', safemsg(error), headers);
1108
+ }
1109
+ return true;
1110
+ }
1111
+ /* POST /auth/login */
1112
+ if (req.method === 'POST' && path === '/auth/login') {
1113
+ const limit = ratelimit(`login:${ip}`, ratelimits.login.limit, ratelimits.login.windowms);
1114
+ if (!limit.allowed) {
1115
+ writeerror(res, 429, 'rate-limited', `too many login attempts; retry in ${limit.retryafter} s`, {
1116
+ ...headers,
1117
+ 'retry-after': String(limit.retryafter),
1118
+ });
1119
+ return true;
1120
+ }
1121
+ try {
1122
+ const body = await readbody(req);
1123
+ if (body === null || typeof body !== 'object') {
1124
+ writeerror(res, 400, 'invalid-body', 'request body must be a json object', headers);
1125
+ return true;
1126
+ }
1127
+ const credentials = body;
1128
+ if (role === 'clone' && mainurl().length > 0 && meshsecret().length > 0) {
1129
+ const forwarded = await forwardauth('login', body);
1130
+ await cacheforwarded(forwarded, req, headers, res);
1131
+ return true;
1132
+ }
1133
+ const user = store.finduserbyname(String(credentials.username ?? ''));
1134
+ let valid = false;
1135
+ if (user === null) {
1136
+ valid = burndummy();
1137
+ }
1138
+ else {
1139
+ valid = verifypassword(String(credentials.password ?? ''), String(user.passwordhash), String(user.salt));
1140
+ }
1141
+ if (user === null || !valid) {
1142
+ store.addaudit({ action: 'login-failed', detail: String(credentials.username ?? ''), ip });
1143
+ writeerror(res, 401, 'invalid-credentials', 'invalid credentials', headers);
1144
+ return true;
1145
+ }
1146
+ const now = new Date().toISOString();
1147
+ store.updatelastlogin(user.id, now);
1148
+ const session = createsession(user.id, { ip, useragent });
1149
+ store.addaudit({ userid: user.id, action: 'login', ip });
1150
+ store.addevent({ topic: 'auth.login', payload: { userid: user.id, username: user.username } });
1151
+ writejson(res, 200, { user: { id: user.id, username: user.username, role: user.role } }, { ...headers, 'set-cookie': cookiefor(session.token, req) });
1152
+ }
1153
+ catch (error) {
1154
+ writeerror(res, error?.status ?? 500, error?.code ?? 'login-failed', safemsg(error), headers);
1155
+ }
1156
+ return true;
1157
+ }
1158
+ /* POST /auth/logout */
1159
+ if (req.method === 'POST' && path === '/auth/logout') {
1160
+ try {
1161
+ const token = extracttoken(req);
1162
+ let userid = null;
1163
+ if (token !== null) {
1164
+ const user = requireauth(req);
1165
+ userid = user?.id ?? null;
1166
+ destroysession(token);
1167
+ }
1168
+ store.addaudit({ userid, action: 'logout', ip });
1169
+ writejson(res, 200, { ok: true }, { ...headers, 'set-cookie': cookieclear() });
1170
+ }
1171
+ catch (error) {
1172
+ writeerror(res, 500, 'logout-failed', safemsg(error), headers);
1173
+ }
1174
+ return true;
1175
+ }
1176
+ /* GET /auth/me */
1177
+ if (req.method === 'GET' && path === '/auth/me') {
1178
+ const user = requireauth(req);
1179
+ if (user === null) {
1180
+ writeerror(res, 401, 'unauthorized', 'authentication required', headers);
1181
+ return true;
1182
+ }
1183
+ writejson(res, 200, { user }, headers);
1184
+ return true;
1185
+ }
1186
+ writeerror(res, 404, 'not-found', `no auth route for ${req.method} ${path}`, headers);
1187
+ return true;
1188
+ }
1189
+ /**
1190
+ * relays one forwarded auth response (clone mode): the main node's
1191
+ * set-cookie is re-issued to the caller and the session row is cached
1192
+ * locally with the remote userid so requireauth works on the clone.
1193
+ *
1194
+ * @param forwarded the main node response.
1195
+ * @param req the incoming request.
1196
+ * @param headers the prepared headers.
1197
+ * @param res the outgoing response.
1198
+ * @returns resolves when the response is written.
1199
+ */
1200
+ async function cacheforwarded(forwarded, req, headers, res) {
1201
+ const ip = clientip(req);
1202
+ const useragent = clientuseragent(req);
1203
+ const cookie = forwarded.setcookie.find((entry) => entry.startsWith('saddlesession='));
1204
+ const user = forwarded.body !== null && typeof forwarded.body === 'object'
1205
+ ? forwarded.body.user
1206
+ : null;
1207
+ if (forwarded.status >= 200 &&
1208
+ forwarded.status < 300 &&
1209
+ typeof cookie === 'string' &&
1210
+ user !== null &&
1211
+ typeof user === 'object') {
1212
+ const token = cookie.split(';')[0].slice('saddlesession='.length);
1213
+ /* cache the remote user row (best effort) so requireauth resolves
1214
+ * sessions minted by the main authority on the clone too */
1215
+ const remoteuser = user;
1216
+ try {
1217
+ if (store.finduserbyid(String(remoteuser.id)) === null) {
1218
+ store.createuser({
1219
+ id: String(remoteuser.id),
1220
+ username: String(remoteuser.username),
1221
+ passwordhash: 'mesh-forwarded',
1222
+ salt: 'mesh-forwarded',
1223
+ role: typeof remoteuser.role === 'string' ? remoteuser.role : 'user',
1224
+ createdat: typeof remoteuser.createdAt === 'string' ? remoteuser.createdAt : undefined,
1225
+ });
1226
+ }
1227
+ }
1228
+ catch {
1229
+ /* the row may already exist under a race; the session below still
1230
+ * resolves through the main node's next forward */
1231
+ }
1232
+ cachesession({
1233
+ token,
1234
+ userid: String(remoteuser.id),
1235
+ expiresat: new Date(Date.now() + sessionttlms).toISOString(),
1236
+ ip,
1237
+ useragent,
1238
+ });
1239
+ writejson(res, forwarded.status, forwarded.body, { ...headers, 'set-cookie': cookie });
1240
+ return;
1241
+ }
1242
+ writeerror(res, forwarded.status >= 400 ? forwarded.status : 502, forwarded.body?.error?.code ?? 'mesh-forward-failed', forwarded.body?.error?.message ?? 'the main node rejected the request', headers);
1243
+ }
1244
+ /* ------------------------------------------------------------------ */
1245
+ /* context: meshroutes */
1246
+ /* ------------------------------------------------------------------ */
1247
+ /**
1248
+ * handles the signed mesh surface: clone registration, heartbeats and
1249
+ * the node listing served by the main/standalone authority. every
1250
+ * request must carry the x-saddle-timestamp and x-saddle-signature
1251
+ * headers verified by mesh.ts.
1252
+ *
1253
+ * @param req the incoming request.
1254
+ * @param res the outgoing response.
1255
+ * @param path the api-relative path.
1256
+ * @param segments the path segments.
1257
+ * @returns true when the request was handled.
1258
+ */
1259
+ async function handlemesh(req, res, path, segments) {
1260
+ if (segments[0] !== 'mesh') {
1261
+ return false;
1262
+ }
1263
+ const headers = respondheaders(req, 'credentials');
1264
+ const secret = meshsecret();
1265
+ if (secret.length === 0) {
1266
+ writeerror(res, 401, 'mesh-unconfigured', 'SADDLE_MESH_SECRET is not configured on this node', headers);
1267
+ return true;
1268
+ }
1269
+ let raw = '';
1270
+ if (req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE') {
1271
+ raw = (await readrawbody(req)).toString('utf8');
1272
+ }
1273
+ const verdict = verifymesh(req, raw, secret);
1274
+ if (!verdict.ok) {
1275
+ writeerror(res, verdict.status, verdict.code, verdict.message, headers);
1276
+ return true;
1277
+ }
1278
+ /* POST /mesh/register */
1279
+ if (req.method === 'POST' && path === '/mesh/register') {
1280
+ try {
1281
+ const body = raw.length === 0 ? {} : JSON.parse(raw);
1282
+ const url = String(body?.url ?? '').trim();
1283
+ if (url.length === 0) {
1284
+ writeerror(res, 400, 'invalid-body', 'url is required', headers);
1285
+ return true;
1286
+ }
1287
+ const node = store.registernode({
1288
+ url,
1289
+ region: typeof body?.region === 'string' ? body.region : undefined,
1290
+ rolename: typeof body?.rolename === 'string' ? body.rolename : undefined,
1291
+ meta: body?.meta === undefined ? undefined : JSON.stringify(body.meta),
1292
+ });
1293
+ store.addaudit({ action: 'mesh-register', detail: url, ip: clientip(req) });
1294
+ store.addevent({ topic: 'mesh.register', payload: { nodeid: node.id, url } });
1295
+ writejson(res, 200, { nodeid: node.id, url: node.url, status: node.status }, headers);
1296
+ }
1297
+ catch {
1298
+ writeerror(res, 400, 'invalid-json', 'request body is not valid json', headers);
1299
+ }
1300
+ return true;
1301
+ }
1302
+ /* POST /mesh/heartbeat */
1303
+ if (req.method === 'POST' && path === '/mesh/heartbeat') {
1304
+ try {
1305
+ const body = raw.length === 0 ? {} : JSON.parse(raw);
1306
+ const nodeid = String(body?.nodeid ?? '');
1307
+ if (nodeid.length === 0) {
1308
+ writeerror(res, 400, 'invalid-body', 'nodeid is required', headers);
1309
+ return true;
1310
+ }
1311
+ const alive = store.heartbeatnode(nodeid, new Date().toISOString());
1312
+ if (!alive) {
1313
+ writeerror(res, 404, 'node-not-found', `no node with id ${nodeid}`, headers);
1314
+ return true;
1315
+ }
1316
+ writejson(res, 200, { ok: true, nodeid }, headers);
1317
+ }
1318
+ catch {
1319
+ writeerror(res, 400, 'invalid-json', 'request body is not valid json', headers);
1320
+ }
1321
+ return true;
1322
+ }
1323
+ /* GET /mesh/nodes — the main (or standalone) authority lists nodes */
1324
+ if (req.method === 'GET' && path === '/mesh/nodes') {
1325
+ if (role === 'clone') {
1326
+ writeerror(res, 403, 'mesh-nodes-main-only', 'the node listing is served by the main node', headers);
1327
+ return true;
1328
+ }
1329
+ writejson(res, 200, { nodes: store.listnodes() }, headers);
1330
+ return true;
1331
+ }
1332
+ writeerror(res, 404, 'not-found', `no mesh route for ${req.method} ${path}`, headers);
1333
+ return true;
1334
+ }
1335
+ /* ------------------------------------------------------------------ */
1336
+ /* context: adminroutes */
1337
+ /* ------------------------------------------------------------------ */
1338
+ /**
1339
+ * handles the admin surface: overview counts, users, nodes, sandboxes
1340
+ * and the audit trail. access requires an authenticated admin user; on
1341
+ * clone nodes the surface answers 403 pointing at the main authority.
1342
+ *
1343
+ * @param req the incoming request.
1344
+ * @param res the outgoing response.
1345
+ * @param path the api-relative path.
1346
+ * @param segments the path segments.
1347
+ * @returns true when the request was handled.
1348
+ */
1349
+ function handleadmin(req, res, path, segments) {
1350
+ if (segments[0] !== 'admin') {
1351
+ return false;
1352
+ }
1353
+ const headers = respondheaders(req, 'credentials');
1354
+ const user = requireauth(req);
1355
+ if (user === null) {
1356
+ writeerror(res, 401, 'unauthorized', 'authentication required', headers);
1357
+ return true;
1358
+ }
1359
+ if (user.role !== 'admin') {
1360
+ writeerror(res, 403, 'forbidden', 'admin role required', headers);
1361
+ return true;
1362
+ }
1363
+ if (role === 'clone') {
1364
+ writeerror(res, 403, 'admin-main-only', 'the admin api is served by the main node', headers);
1365
+ return true;
1366
+ }
1367
+ if (req.method !== 'GET') {
1368
+ writeerror(res, 405, 'method-not-allowed', 'admin routes accept GET only', headers);
1369
+ return true;
1370
+ }
1371
+ try {
1372
+ if (path === '/admin/overview') {
1373
+ const counts = store.counts();
1374
+ writejson(res, 200, {
1375
+ counts: {
1376
+ users: counts.users,
1377
+ nodes: counts.nodes,
1378
+ sandboxes: counts.sandboxes,
1379
+ sessions: counts.sessions,
1380
+ events: counts.events,
1381
+ },
1382
+ uptime: Math.round(process.uptime()),
1383
+ role,
1384
+ version,
1385
+ }, headers);
1386
+ return true;
1387
+ }
1388
+ if (path === '/admin/users') {
1389
+ const users = store.listusers().map((row) => ({
1390
+ id: row.id,
1391
+ username: row.username,
1392
+ role: row.role,
1393
+ createdat: row.createdat,
1394
+ lastlogin: row.lastlogin,
1395
+ }));
1396
+ writejson(res, 200, { users }, headers);
1397
+ return true;
1398
+ }
1399
+ if (path === '/admin/nodes') {
1400
+ writejson(res, 200, { nodes: store.listnodes() }, headers);
1401
+ return true;
1402
+ }
1403
+ if (path === '/admin/sandboxes') {
1404
+ writejson(res, 200, { sandboxes: store.listsandboxes() }, headers);
1405
+ return true;
1406
+ }
1407
+ if (path === '/admin/audit') {
1408
+ writejson(res, 200, { audit: store.listaudit() }, headers);
1409
+ return true;
1410
+ }
1411
+ }
1412
+ catch (error) {
1413
+ writeerror(res, 500, 'admin-query-failed', safemsg(error), headers);
1414
+ return true;
1415
+ }
1416
+ writeerror(res, 404, 'not-found', `no admin route for ${req.method} ${path}`, headers);
1417
+ return true;
1418
+ }
1419
+ /* ------------------------------------------------------------------ */
1420
+ /* context: eventsroute */
1421
+ /* ------------------------------------------------------------------ */
1422
+ /**
1423
+ * handles GET /api/v1/events?since=<id>: the dashboard poll over the
1424
+ * durable events table (auth, sandbox and mesh topics).
1425
+ *
1426
+ * @param req the incoming request.
1427
+ * @param res the outgoing response.
1428
+ * @param url the parsed request url.
1429
+ * @returns true when the request was handled.
1430
+ */
1431
+ function handleevents(req, res, url) {
1432
+ const headers = respondheaders(req, 'credentials');
1433
+ /** resolves whether a sandbox id belongs to the caller (event scoping). */
1434
+ const minehas = (candidate) => {
1435
+ try {
1436
+ const row = store.findsandboxbyid(candidate);
1437
+ return row !== undefined && row.userid === user?.id;
1438
+ }
1439
+ catch {
1440
+ return false;
1441
+ }
1442
+ };
1443
+ const user = requireauth(req);
1444
+ if (user === null) {
1445
+ writeerror(res, 401, 'unauthorized', 'authentication required', headers);
1446
+ return true;
1447
+ }
1448
+ if (req.method !== 'GET') {
1449
+ writeerror(res, 405, 'method-not-allowed', 'events accepts GET only', headers);
1450
+ return true;
1451
+ }
1452
+ try {
1453
+ const since = Math.max(0, Number.parseInt(url.searchParams.get('since') ?? '0', 10) || 0);
1454
+ const isadmin = user.role === 'admin';
1455
+ const rows = store.listevents(since, 100);
1456
+ // cross-tenant privacy: regular users only see events whose payload
1457
+ // references their own user id (auth topics) or their sandbox ids; the
1458
+ // admin role keeps the global stream for the management dashboard.
1459
+ const events = [];
1460
+ for (const row of rows) {
1461
+ const payload = parsetolerant(row.payload);
1462
+ if (isadmin === false) {
1463
+ const owns = payload?.userid === user.id ||
1464
+ (typeof payload?.id === 'string' && minehas(payload.id));
1465
+ if (owns === false)
1466
+ continue;
1467
+ }
1468
+ events.push({
1469
+ id: row.id,
1470
+ topic: row.topic,
1471
+ payload,
1472
+ nodeid: row.nodeid,
1473
+ createdAt: row.createdat,
1474
+ });
1475
+ }
1476
+ const lastid = events.length > 0 ? events[events.length - 1].id : since;
1477
+ writejson(res, 200, { events, lastid }, headers);
1478
+ }
1479
+ catch (error) {
1480
+ writeerror(res, 500, 'events-query-failed', safemsg(error), headers);
1481
+ }
1482
+ return true;
1483
+ }
1484
+ /**
1485
+ * parses one json payload string, tolerating null and plain strings.
1486
+ *
1487
+ * @param value the stored payload.
1488
+ * @returns the parsed value or the raw string.
1489
+ */
1490
+ function parsetolerant(value) {
1491
+ if (value === null || value === undefined) {
1492
+ return null;
1493
+ }
1494
+ try {
1495
+ return JSON.parse(value);
1496
+ }
1497
+ catch {
1498
+ return value;
1499
+ }
1500
+ }
1501
+ /* ------------------------------------------------------------------ */
1502
+ /* context: routing and api endpoints */
1503
+ /* ------------------------------------------------------------------ */
1504
+ /**
1505
+ * executes one command against a sandbox record and updates the counters;
1506
+ * shared by the exec route.
1507
+ *
1508
+ * @param record the sandbox record.
1509
+ * @param body the parsed request body.
1510
+ * @param res the outgoing response.
1511
+ * @param headers the prepared headers.
1512
+ * @returns void.
1513
+ */
1514
+ function execcommand(record, body, res, headers) {
1515
+ const command = String(body?.command ?? '');
1516
+ if (command.trim().length === 0) {
1517
+ writeerror(res, 400, 'invalid-command', 'command must be a non-empty string', headers);
1518
+ return;
1519
+ }
1520
+ const head = command.trim().split(/\s+/)[0];
1521
+ const mutatesworkspace = head === 'touch' || head === 'rm' || (head === 'echo' && /\s(>>|>)\s*\S/.test(command));
1522
+ const started = performance.now();
1523
+ const result = dispatch(command, record.engine, record.fscontext);
1524
+ const durationms = Math.max(0, Math.round((performance.now() - started) * 1000) / 1000);
1525
+ record.execcount += 1;
1526
+ record.lastcommand = command;
1527
+ if (mutatesworkspace) {
1528
+ refreshusage(record);
1529
+ }
1530
+ writejson(res, 200, {
1531
+ output: result.output,
1532
+ exitCode: result.exitCode,
1533
+ durationMs: durationms,
1534
+ }, headers);
1535
+ }
1536
+ /* ------------------------------------------------------------------ */
1537
+ /* context: edgeplane — the artifact vault of the edge execution lane */
1538
+ /* ------------------------------------------------------------------ */
1539
+ /** the edge artifact vault: the virtual.ts localstorage adapter rooted
1540
+ * in a private directory under the os tmpdir (outside the served spa
1541
+ * root, so no artifact is ever statically served). the module loads
1542
+ * lazily because the plain node type-stripping host cannot resolve the
1543
+ * root domain graph (virtual.ts imports ./foundation.js), so on that
1544
+ * host the run answers without artifact persistence while the audit
1545
+ * and events rows still record it; on bundler hosts (bun, vite,
1546
+ * vitest) the vault loads and every artifact lands on disk. wiring the
1547
+ * sqlite artifact tables instead would need db.ts schema changes
1548
+ * outside this wave's allowed files - noted honestly here. */
1549
+ let edgevault;
1550
+ async function edgestorage() {
1551
+ if (edgevault !== undefined) {
1552
+ return edgevault;
1553
+ }
1554
+ try {
1555
+ const { localstorage } = await import('./virtual.ts');
1556
+ edgevault = localstorage(join(tmpdir(), 'saddle-edgeartifacts'));
1557
+ }
1558
+ catch {
1559
+ edgevault = null;
1560
+ }
1561
+ return edgevault;
1562
+ }
1563
+ /**
1564
+ * handles one /api/v1 request.
1565
+ *
1566
+ * @param req the incoming request.
1567
+ * @param res the outgoing response.
1568
+ * @param url the parsed request url.
1569
+ * @returns resolves when the response is written.
1570
+ */
1571
+ async function handleapi(req, res, url) {
1572
+ const path = url.pathname.replace(/^\/api\/v1/, '') || '/';
1573
+ const segments = path.split('/').filter((segment) => segment.length > 0);
1574
+ if (req.method === 'OPTIONS') {
1575
+ const origin = req.headers?.origin;
1576
+ const credentialed = typeof origin === 'string' && allowedorigins().includes(origin);
1577
+ res.writeHead(204, {
1578
+ ...securityheaders(req),
1579
+ ...corsheaders(req, credentialed ? 'credentials' : 'public'),
1580
+ 'access-control-max-age': '86400',
1581
+ });
1582
+ res.end();
1583
+ return;
1584
+ }
1585
+ /* GET /health — public */
1586
+ if (req.method === 'GET' && path === '/health') {
1587
+ writejson(res, 200, {
1588
+ ok: true,
1589
+ version,
1590
+ uptime: Math.round(process.uptime()),
1591
+ role,
1592
+ db: store.healthcheck(),
1593
+ sandboxquota: store.sandboxquota(),
1594
+ }, respondheaders(req, 'public'));
1595
+ return;
1596
+ }
1597
+ /* GET /specs/cpus | /specs/gpus | /specs/memory — public */
1598
+ if (req.method === 'GET' && segments[0] === 'specs' && segments.length === 2) {
1599
+ const headers = respondheaders(req, 'public');
1600
+ try {
1601
+ const document = await getspeccatalog(segments[1]);
1602
+ writejson(res, 200, document, headers);
1603
+ }
1604
+ catch (error) {
1605
+ writeerror(res, 404, 'unknown-catalog', safemsg(error), headers);
1606
+ }
1607
+ return;
1608
+ }
1609
+ /* ---------------------------------------------------------------- */
1610
+ /* context: edgeplane — the edge execution law made executable: */
1611
+ /* GET /modalities the ninety modality plan, honestly counted */
1612
+ /* GET /edge/lanes the probed lane table of the edge plane */
1613
+ /* POST /edge/run ride one modality lane server-side (auth) */
1614
+ /* ---------------------------------------------------------------- */
1615
+ /* GET /modalities — public: the registry census of the ninety plan */
1616
+ if (req.method === 'GET' && path === '/modalities') {
1617
+ const headers = respondheaders(req, 'public');
1618
+ try {
1619
+ const report = planreport();
1620
+ writejson(res, 200, {
1621
+ plancount: MODALITYPLANCOUNT,
1622
+ live: report.live,
1623
+ host: report.host,
1624
+ planned: report.planned,
1625
+ records: MODALITIES,
1626
+ }, headers);
1627
+ }
1628
+ catch (error) {
1629
+ writeerror(res, 500, 'modalities-failed', safemsg(error), headers);
1630
+ }
1631
+ return;
1632
+ }
1633
+ /* GET /edge/lanes — public: the probed lane table of the edge plane */
1634
+ if (req.method === 'GET' && path === '/edge/lanes') {
1635
+ const headers = respondheaders(req, 'public');
1636
+ try {
1637
+ writejson(res, 200, { lanes: edgelandesreport() }, headers);
1638
+ }
1639
+ catch (error) {
1640
+ writeerror(res, 500, 'lanes-failed', safemsg(error), headers);
1641
+ }
1642
+ return;
1643
+ }
1644
+ /* GET /edge/workloads — public: the family workload census of the
1645
+ workload law — every official app's lanes joined against the registry
1646
+ of record, the honest verdicts (dispatchable, planned-only, the
1647
+ app-lane migration list) and the probed lane table of the plane read
1648
+ beside it, so the ops surface answers the whole picture in one round
1649
+ trip: what the family asks, what the registry answers, what the plane
1650
+ probes today. */
1651
+ if (req.method === 'GET' && path === '/edge/workloads') {
1652
+ const headers = respondheaders(req, 'public');
1653
+ try {
1654
+ writejson(res, 200, { report: workloadreport(), lanes: edgelandesreport(), apps: CATALOGAPPS }, headers);
1655
+ }
1656
+ catch (error) {
1657
+ writeerror(res, 500, 'workloads-failed', safemsg(error), headers);
1658
+ }
1659
+ return;
1660
+ }
1661
+ /* GET /edge/join?app=&lane= — public: the nuclear modality law as a
1662
+ lookup — the workload names its app and lane, the resolver reads the
1663
+ demand the catalog carries and answers the modality set that satisfies
1664
+ it (best candidate first, the honest verdict beside it). the person
1665
+ never names a modality: the platform resolves the join. 404 answers an
1666
+ app or lane the catalog does not carry (the honest refusal, never a
1667
+ silent empty join). */
1668
+ if (req.method === 'GET' && path === '/edge/join') {
1669
+ const headers = respondheaders(req, 'public');
1670
+ const app = url.searchParams.get('app') ?? '';
1671
+ const lane = url.searchParams.get('lane') ?? '';
1672
+ if (app.length === 0 || lane.length === 0) {
1673
+ writeerror(res, 400, 'invalid-join-request', 'the join lookup requires an app and a lane', headers);
1674
+ return;
1675
+ }
1676
+ if (!CATALOGAPPS.includes(app)) {
1677
+ writeerror(res, 404, 'unknown-app', `the app ${app} is not one the workload catalog carries`, headers);
1678
+ return;
1679
+ }
1680
+ const join = familyjoin(app, lane);
1681
+ if (join === undefined) {
1682
+ writeerror(res, 404, 'unknown-lane', `the app ${app} carries no lane ${lane}`, headers);
1683
+ return;
1684
+ }
1685
+ try {
1686
+ writejson(res, 200, {
1687
+ app: join.lane.app,
1688
+ lane: join.lane.lane,
1689
+ role: join.lane.role,
1690
+ demand: join.lane.demand,
1691
+ today: join.lane.today,
1692
+ verdict: join.verdict,
1693
+ livecount: join.livecount,
1694
+ candidates: join.candidates.map((candidate) => ({
1695
+ id: candidate.id,
1696
+ label: candidate.label,
1697
+ state: candidate.state,
1698
+ isolation: candidate.isolation,
1699
+ bootms: candidate.bootms,
1700
+ gpu: candidate.gpu,
1701
+ network: candidate.network,
1702
+ })),
1703
+ }, headers);
1704
+ }
1705
+ catch (error) {
1706
+ writeerror(res, 500, 'join-failed', safemsg(error), headers);
1707
+ }
1708
+ return;
1709
+ }
1710
+ /* POST /edge/run — auth: ride one modality lane of the engine, the
1711
+ visitor browser executes nothing (the edge execution law). the
1712
+ artifact commits through the lazy edge vault (see edgestorage) and
1713
+ every run records an audit row plus an events row the same way the
1714
+ sandbox surface does. 422 answers a planned or unknown modality,
1715
+ 503 answers a lane whose host runtime is absent — that body keeps
1716
+ the runtime name because the absence is the honest lane report,
1717
+ not an internal secret the 5xx guard exists to hide. */
1718
+ if (req.method === 'POST' && path === '/edge/run') {
1719
+ const headers = respondheaders(req, 'credentials');
1720
+ const user = requireauth(req);
1721
+ if (user === null) {
1722
+ writeerror(res, 401, 'unauthorized', 'authentication required', headers);
1723
+ return;
1724
+ }
1725
+ try {
1726
+ const body = await readbody(req);
1727
+ if (body === null || typeof body !== 'object') {
1728
+ writeerror(res, 400, 'invalid-body', 'request body must be a json object', headers);
1729
+ return;
1730
+ }
1731
+ const spec = body;
1732
+ if (typeof spec.modality !== 'string' || spec.modality.length === 0 || typeof spec.code !== 'string') {
1733
+ writeerror(res, 400, 'invalid-edge-request', 'the edge run requires a modality id and a code payload', headers);
1734
+ return;
1735
+ }
1736
+ const result = await runonedge({
1737
+ modality: spec.modality,
1738
+ code: spec.code,
1739
+ inputs: spec.inputs,
1740
+ deadlinems: typeof spec.deadlinems === 'number' && Number.isFinite(spec.deadlinems) ? spec.deadlinems : undefined,
1741
+ }, { storage: (await edgestorage()) ?? undefined });
1742
+ try {
1743
+ store.addaudit({
1744
+ userid: user.id,
1745
+ action: 'edge-run',
1746
+ detail: `${result.modality}:${result.status}`,
1747
+ ip: clientip(req),
1748
+ });
1749
+ store.addevent({
1750
+ topic: 'edge.run',
1751
+ payload: {
1752
+ userid: user.id,
1753
+ runid: result.runid,
1754
+ modality: result.modality,
1755
+ lane: result.lane,
1756
+ status: result.status,
1757
+ error: result.error?.code ?? null,
1758
+ },
1759
+ });
1760
+ }
1761
+ catch {
1762
+ /* the run already answered; the bookkeeping is best effort */
1763
+ }
1764
+ if (result.status === 'succeeded') {
1765
+ writejson(res, 200, result, headers);
1766
+ return;
1767
+ }
1768
+ if (result.error?.code === 'MODALITY_NOT_LIVE') {
1769
+ writeerror(res, 422, 'modality-not-live', result.error.message, headers);
1770
+ return;
1771
+ }
1772
+ if (result.error?.code === 'LANE_NOT_WIRED_IN_PLANE') {
1773
+ // the registry already drives the modality live through another
1774
+ // engine surface: this plane answers not-implemented, never a lie
1775
+ // about the modality's state.
1776
+ writeerror(res, 501, 'lane-not-wired-in-plane', result.error.message, headers);
1777
+ return;
1778
+ }
1779
+ if (result.error?.code === 'HOST_RUNTIME_ABSENT') {
1780
+ writejson(res, 503, { error: { code: 'host-runtime-absent', message: result.error.message } }, headers);
1781
+ return;
1782
+ }
1783
+ writeerror(res, 500, 'edge-run-failed', result.error?.message ?? 'the edge run failed', headers);
1784
+ }
1785
+ catch (error) {
1786
+ writeerror(res, error?.status ?? 400, error?.code ?? 'edge-run-failed', safemsg(error), headers);
1787
+ }
1788
+ return;
1789
+ }
1790
+ /* /auth/* */
1791
+ if (await handleauth(req, res, path, segments)) {
1792
+ return;
1793
+ }
1794
+ /* /mesh/* */
1795
+ if (await handlemesh(req, res, path, segments)) {
1796
+ return;
1797
+ }
1798
+ /* /admin/* */
1799
+ if (handleadmin(req, res, path, segments)) {
1800
+ return;
1801
+ }
1802
+ /* GET /events?since= */
1803
+ if (segments[0] === 'events') {
1804
+ handleevents(req, res, url);
1805
+ return;
1806
+ }
1807
+ /* POST /sandboxes — requires an authenticated user */
1808
+ if (req.method === 'POST' && path === '/sandboxes') {
1809
+ const headers = respondheaders(req, 'credentials');
1810
+ const user = requireauth(req);
1811
+ if (user === null) {
1812
+ writeerror(res, 401, 'unauthorized', 'authentication required', headers);
1813
+ return;
1814
+ }
1815
+ try {
1816
+ const body = await readbody(req);
1817
+ if (body === null || typeof body !== 'object') {
1818
+ writeerror(res, 400, 'invalid-body', 'request body must be a json object', headers);
1819
+ return;
1820
+ }
1821
+ const record = createsandbox(body, user);
1822
+ store.addaudit({
1823
+ userid: user.id,
1824
+ action: 'sandbox-create',
1825
+ detail: record.id,
1826
+ ip: clientip(req),
1827
+ });
1828
+ writejson(res, 201, publicview(record), headers);
1829
+ }
1830
+ catch (error) {
1831
+ writeerror(res, error?.status ?? 400, error?.code ?? 'invalid-request', safemsg(error), headers);
1832
+ }
1833
+ return;
1834
+ }
1835
+ /* GET /sandboxes - the caller container shelf: every sandbox row of the
1836
+ authenticated user, live and expired alike, with workspace usage so
1837
+ the dashboard can offer "take your container back" on any id whose
1838
+ files survived (expired keeps files by design until manual delete). */
1839
+ if (segments[0] === 'sandboxes' && segments.length === 1 && req.method === 'GET') {
1840
+ const headers = respondheaders(req, 'credentials');
1841
+ const user = requireauth(req);
1842
+ if (user === null) {
1843
+ writeerror(res, 401, 'unauthorized', 'authentication required', headers);
1844
+ return;
1845
+ }
1846
+ try {
1847
+ const rows = store.listsandboxesbyuser(user.id);
1848
+ const shelf = rows.map((row) => {
1849
+ const live = sandboxes.get(row.id);
1850
+ let usage = { files: 0, bytes: 0 };
1851
+ try {
1852
+ usage = store.sandboxusage(row.id);
1853
+ }
1854
+ catch {
1855
+ /* empty workspace */
1856
+ }
1857
+ return {
1858
+ id: row.id,
1859
+ state: live === undefined ? row.state : live.state,
1860
+ model: row.model,
1861
+ vcpus: row.vcpus,
1862
+ ramgb: row.ramgb,
1863
+ gpu: row.gpu,
1864
+ createdAt: row.createdat,
1865
+ expiresAt: row.expiresat,
1866
+ files: usage.files,
1867
+ bytes: usage.bytes,
1868
+ resumable: usage.files > 0,
1869
+ };
1870
+ });
1871
+ writejson(res, 200, { sandboxes: shelf }, headers);
1872
+ }
1873
+ catch (error) {
1874
+ writeerror(res, 500, 'shelf-failed', safemsg(error), headers);
1875
+ }
1876
+ return;
1877
+ }
1878
+ /* GET /sandboxes/:id | DELETE /sandboxes/:id */
1879
+ if (segments[0] === 'sandboxes' && segments.length === 2) {
1880
+ const headers = respondheaders(req, 'credentials');
1881
+ const user = requireauth(req);
1882
+ if (user === null) {
1883
+ writeerror(res, 401, 'unauthorized', 'authentication required', headers);
1884
+ return;
1885
+ }
1886
+ const record = sandboxes.get(segments[1]);
1887
+ if (record === undefined) {
1888
+ // expired (or post-restart) sandboxes live on as db rows with their
1889
+ // workspace files; a DELETE here is the manual purge the shelf
1890
+ // promises - no resume round trip required.
1891
+ if (req.method === 'DELETE') {
1892
+ try {
1893
+ const row = store.findsandboxbyid(String(segments[1] ?? ''));
1894
+ if (row == null || row.userid !== user.id) {
1895
+ writeerror(res, 404, 'sandbox-not-found', `no sandbox with id ${segments[1]}`, headers);
1896
+ return;
1897
+ }
1898
+ store.updatesandboxstate(segments[1], 'destroyed');
1899
+ store.deletesandboxfiles(segments[1]);
1900
+ store.addaudit({
1901
+ userid: user.id,
1902
+ action: 'sandbox-delete',
1903
+ detail: segments[1],
1904
+ ip: clientip(req),
1905
+ });
1906
+ writejson(res, 200, { id: segments[1], state: 'destroyed', purged: true }, headers);
1907
+ return;
1908
+ }
1909
+ catch (error) {
1910
+ writeerror(res, 500, 'sandbox-purge-failed', safemsg(error), headers);
1911
+ return;
1912
+ }
1913
+ }
1914
+ writeerror(res, 404, 'sandbox-not-found', `no sandbox with id ${segments[1]}`, headers);
1915
+ return;
1916
+ }
1917
+ if (!ownssandbox(record, user)) {
1918
+ writeerror(res, 403, 'sandbox-forbidden', 'sandbox belongs to another user', headers);
1919
+ return;
1920
+ }
1921
+ if (req.method === 'GET') {
1922
+ writejson(res, 200, publicview(record), headers);
1923
+ return;
1924
+ }
1925
+ if (req.method === 'DELETE') {
1926
+ destroysandbox(record);
1927
+ store.addaudit({
1928
+ userid: user.id,
1929
+ action: 'sandbox-delete',
1930
+ detail: record.id,
1931
+ ip: clientip(req),
1932
+ });
1933
+ writejson(res, 200, { id: segments[1], state: 'destroyed' }, headers);
1934
+ return;
1935
+ }
1936
+ writeerror(res, 405, 'method-not-allowed', `method ${req.method} not allowed on /sandboxes/:id`, headers);
1937
+ return;
1938
+ }
1939
+ /* POST /sandboxes/:id/resume - take the container back: rebuilds the
1940
+ in-memory engine from the stored spec row, restores the persistent
1941
+ workspace binding and renews the ttl; files never left the id. */
1942
+ if (segments[0] === 'sandboxes' && segments.length === 3 && segments[2] === 'resume' && req.method === 'POST') {
1943
+ const headers = respondheaders(req, 'credentials');
1944
+ const user = requireauth(req);
1945
+ if (user === null) {
1946
+ writeerror(res, 401, 'unauthorized', 'authentication required', headers);
1947
+ return;
1948
+ }
1949
+ const id = decodeURIComponent(segments[1] ?? '');
1950
+ try {
1951
+ const row = store.findsandboxbyid(id);
1952
+ if (row == null || row.userid !== user.id) {
1953
+ writeerror(res, 404, 'not-found', 'sandbox not found for this user', headers);
1954
+ return;
1955
+ }
1956
+ if (sandboxes.has(id)) {
1957
+ writejson(res, 200, publicview(sandboxes.get(id)), headers);
1958
+ return;
1959
+ }
1960
+ const engine = createSandboxState({
1961
+ model: row.model,
1962
+ vcpus: row.vcpus,
1963
+ ramgb: row.ramgb,
1964
+ gpu: row.gpu,
1965
+ mig: 'off',
1966
+ id,
1967
+ });
1968
+ const now = Date.now();
1969
+ const record = {
1970
+ id,
1971
+ userid: user.id,
1972
+ state: 'running',
1973
+ spec: { model: row.model, vcpus: row.vcpus, ramgb: row.ramgb, gpu: row.gpu, mig: 'off' },
1974
+ createdat: Date.parse(row.createdat) || now,
1975
+ startedat: now,
1976
+ expiresat: now + ttlms,
1977
+ execcount: 0,
1978
+ lastcommand: null,
1979
+ usage: store.sandboxusage(id),
1980
+ engine,
1981
+ fscontext: sandboxfscontext(id, row.quotabytes ?? undefined),
1982
+ };
1983
+ sandboxes.set(id, record);
1984
+ store.updatesandboxstate(id, 'running');
1985
+ store.addaudit({ userid: user.id, action: 'sandbox-resume', detail: id, ip: clientip(req) });
1986
+ store.addevent({ topic: 'sandbox.resumed', payload: { id } });
1987
+ writejson(res, 200, publicview(record), headers);
1988
+ }
1989
+ catch (error) {
1990
+ writeerror(res, 500, 'resume-failed', safemsg(error), headers);
1991
+ }
1992
+ return;
1993
+ }
1994
+ /* POST /sandboxes/:id/exec */
1995
+ if (segments[0] === 'sandboxes' && segments.length === 3 && segments[2] === 'exec') {
1996
+ const headers = respondheaders(req, 'credentials');
1997
+ const user = requireauth(req);
1998
+ if (user === null) {
1999
+ writeerror(res, 401, 'unauthorized', 'authentication required', headers);
2000
+ return;
2001
+ }
2002
+ const record = sandboxes.get(segments[1]);
2003
+ if (record === undefined) {
2004
+ writeerror(res, 404, 'sandbox-not-found', `no sandbox with id ${segments[1]}`, headers);
2005
+ return;
2006
+ }
2007
+ if (!ownssandbox(record, user)) {
2008
+ writeerror(res, 403, 'sandbox-forbidden', 'sandbox belongs to another user', headers);
2009
+ return;
2010
+ }
2011
+ if (req.method !== 'POST') {
2012
+ writeerror(res, 405, 'method-not-allowed', 'exec accepts POST only', headers);
2013
+ return;
2014
+ }
2015
+ try {
2016
+ const body = await readbody(req);
2017
+ execcommand(record, body, res, headers);
2018
+ }
2019
+ catch (error) {
2020
+ writeerror(res, error?.status ?? 500, error?.code ?? 'exec-failed', safemsg(error), headers);
2021
+ }
2022
+ return;
2023
+ }
2024
+ /* ---------------------------------------------------------------- */
2025
+ /* context: filesroutes — the persistent workspace surface */
2026
+ /* GET /sandboxes/:id/files list the workspace of one sandbox */
2027
+ /* GET /sandboxes/:id/files/<path> read one stored file */
2028
+ /* DELETE /sandboxes/:id/files/<path> remove one stored file */
2029
+ /* ownership resolves through the in-memory record when present and */
2030
+ /* falls back to the sandboxes row, so the files of an expired or */
2031
+ /* restarted sandbox stay addressable by the owner (self-contained). */
2032
+ /* ---------------------------------------------------------------- */
2033
+ if (segments[0] === 'sandboxes' && segments.length >= 3 && segments[2] === 'files') {
2034
+ const headers = respondheaders(req, 'credentials');
2035
+ const user = requireauth(req);
2036
+ if (user === null) {
2037
+ writeerror(res, 401, 'unauthorized', 'authentication required', headers);
2038
+ return;
2039
+ }
2040
+ const id = segments[1];
2041
+ const record = sandboxes.get(id);
2042
+ let known = record !== undefined;
2043
+ let ownerok = false;
2044
+ if (record !== undefined) {
2045
+ ownerok = ownssandbox(record, user);
2046
+ }
2047
+ else {
2048
+ try {
2049
+ const row = store.findsandboxbyid(id);
2050
+ known = row !== null;
2051
+ ownerok = known && (row.userid === user.id || user.role === 'admin');
2052
+ }
2053
+ catch {
2054
+ known = false;
2055
+ ownerok = false;
2056
+ }
2057
+ }
2058
+ if (!known) {
2059
+ writeerror(res, 404, 'sandbox-not-found', `no sandbox with id ${id}`, headers);
2060
+ return;
2061
+ }
2062
+ if (!ownerok) {
2063
+ writeerror(res, 403, 'sandbox-forbidden', 'sandbox belongs to another user', headers);
2064
+ return;
2065
+ }
2066
+ try {
2067
+ if (segments.length === 3) {
2068
+ if (req.method !== 'GET') {
2069
+ writeerror(res, 405, 'method-not-allowed', 'the files listing accepts GET only', headers);
2070
+ return;
2071
+ }
2072
+ const files = store.listfiles(id);
2073
+ const usage = store.sandboxusage(id);
2074
+ if (record !== undefined) {
2075
+ record.usage = usage;
2076
+ }
2077
+ writejson(res, 200, { files, usage, quota: store.sandboxquota() }, headers);
2078
+ return;
2079
+ }
2080
+ let filepath = '';
2081
+ try {
2082
+ filepath = decodeURIComponent(segments.slice(3).join('/'));
2083
+ }
2084
+ catch {
2085
+ filepath = segments.slice(3).join('/');
2086
+ }
2087
+ if (filepath.length > 0 && !filepath.startsWith('/')) {
2088
+ filepath = `/${filepath}`;
2089
+ }
2090
+ if (req.method === 'GET') {
2091
+ const file = store.readfile(id, filepath);
2092
+ if (file === null) {
2093
+ writeerror(res, 404, 'file-not-found', `no file ${filepath} in sandbox ${id}`, headers);
2094
+ return;
2095
+ }
2096
+ writejson(res, 200, file, headers);
2097
+ return;
2098
+ }
2099
+ if (req.method === 'DELETE') {
2100
+ const removed = store.deletefile(id, filepath);
2101
+ if (!removed) {
2102
+ writeerror(res, 404, 'file-not-found', `no file ${filepath} in sandbox ${id}`, headers);
2103
+ return;
2104
+ }
2105
+ const usage = store.sandboxusage(id);
2106
+ if (record !== undefined) {
2107
+ record.usage = usage;
2108
+ }
2109
+ try {
2110
+ store.updatesandboxusage(id, usage.files, usage.bytes);
2111
+ }
2112
+ catch {
2113
+ /* usage bookkeeping is best effort */
2114
+ }
2115
+ writejson(res, 200, { ok: true, path: filepath }, headers);
2116
+ return;
2117
+ }
2118
+ writeerror(res, 405, 'method-not-allowed', `method ${req.method} not allowed on sandbox files`, headers);
2119
+ }
2120
+ catch (error) {
2121
+ writeerror(res, 500, 'files-query-failed', safemsg(error), headers);
2122
+ }
2123
+ return;
2124
+ }
2125
+ writeerror(res, 404, 'not-found', `no api route for ${req.method} ${path}`, respondheaders(req, 'public'));
2126
+ }
2127
+ /* ------------------------------------------------------------------ */
2128
+ /* context: httpserver, requestlogging and gracefulshutdown */
2129
+ /* ------------------------------------------------------------------ */
2130
+ /** the http server: static files plus the /api/v1 router. */
2131
+ const server = createServer((req, res) => {
2132
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'saddle.internal'}`);
2133
+ if (url.pathname === '/api/v1' || url.pathname.startsWith('/api/v1/')) {
2134
+ handleapi(req, res, url).catch((error) => {
2135
+ writeerror(res, 500, 'internal', safemsg(error));
2136
+ });
2137
+ return;
2138
+ }
2139
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
2140
+ writeerror(res, 405, 'method-not-allowed', 'static assets accept GET only');
2141
+ return;
2142
+ }
2143
+ // malformed percent sequences (/%zz) must answer 400, never crash the
2144
+ // process: a failed decode is a client error, not a server shutdown.
2145
+ let decodedpath;
2146
+ try {
2147
+ decodedpath = decodeURIComponent(url.pathname);
2148
+ }
2149
+ catch {
2150
+ writeerror(res, 400, 'bad-path', 'malformed percent-encoding in path');
2151
+ return;
2152
+ }
2153
+ servestatic(decodedpath, res, req);
2154
+ });
2155
+ /* graceful shutdown on sigint/sigterm: stops accepting, flushes, closes
2156
+ * the database and exits. */
2157
+ for (const signal of ['SIGINT', 'SIGTERM']) {
2158
+ process.on(signal, () => {
2159
+ try {
2160
+ server.close(() => {
2161
+ store.close();
2162
+ process.exit(0);
2163
+ });
2164
+ setTimeout(() => {
2165
+ store.close();
2166
+ process.exit(0);
2167
+ }, 1500).unref();
2168
+ }
2169
+ catch {
2170
+ process.exit(0);
2171
+ }
2172
+ });
2173
+ }
2174
+ startreaper();
2175
+ /* the main authority sweeps expired sessions every 10 minutes. */
2176
+ if (role === 'main') {
2177
+ const sweeper = setInterval(() => {
2178
+ try {
2179
+ store.cleansessions();
2180
+ }
2181
+ catch {
2182
+ /* the sweep never crashes the server */
2183
+ }
2184
+ }, sessionsweepperiodms);
2185
+ sweeper.unref?.();
2186
+ }
2187
+ /* clone nodes announce themselves to the main registry every 60 s. */
2188
+ if (role === 'clone') {
2189
+ startheartbeat({
2190
+ url: String(process.env.SADDLE_NODE_URL ?? ''),
2191
+ region: String(process.env.SADDLE_REGION ?? ''),
2192
+ rolename: 'clone',
2193
+ });
2194
+ }
2195
+ /* seed the CODEOWNERS admin accounts (idempotent) so a fresh or wiped
2196
+ * database always carries the admin allowlist; only the CODEOWNERS accounts
2197
+ * (iakadion, inathlan, aasblor, nasblor) are admins. the bootstrap password
2198
+ * resolves from SADDLE_ADMIN_SEED_PASSWORD, and a bare boot without the env
2199
+ * mints one per-boot secret that seedadmins surfaces on the console once -
2200
+ * the public source never carries the credential. */
2201
+ const seededadmins = seedadmins();
2202
+ if (seededadmins.length > 0) {
2203
+ process.stdout.write(`admin seed: ${seededadmins.join(', ')} (CODEOWNERS allowlist)\n`);
2204
+ }
2205
+ server.listen(port, host, () => {
2206
+ process.stdout.write([
2207
+ `saddle web sandbox api v${version} (self-hosted node, zero deps, no serverless functions)`,
2208
+ `role: ${role}${role === 'clone' && mainurl().length > 0 ? ` -> main ${mainurl()}` : ''}`,
2209
+ `listening on ${host}:${port} (random default range 30000-59999; override with --port or PORT)`,
2210
+ `spa root: ${webdir} (react build; extensionless routes fall back to index.html; ${mimetable.size} content types from web/mime.types; build with "npm run web:build")`,
2211
+ `db: ${store.dbpath} (node:sqlite, mode 0o600)`,
2212
+ 'endpoints: GET /api/v1/health | GET /api/v1/sandboxes (shelf) | POST /api/v1/sandboxes/:id/resume |',
2213
+ ' POST /api/v1/auth/register | POST /api/v1/auth/login | POST /api/v1/auth/logout |',
2214
+ ' GET /api/v1/auth/me | POST /api/v1/sandboxes (auth) | GET /api/v1/sandboxes/:id |',
2215
+ ' POST /api/v1/sandboxes/:id/exec | DELETE /api/v1/sandboxes/:id | GET /api/v1/events?since=',
2216
+ ' GET /api/v1/sandboxes/:id/files | GET/DELETE /api/v1/sandboxes/:id/files/<path> |',
2217
+ ' GET /api/v1/modalities | GET /api/v1/edge/lanes | POST /api/v1/edge/run (auth) |',
2218
+ ' GET /api/v1/edge/workloads | GET /api/v1/edge/join?app=&lane= |',
2219
+ ' POST /api/v1/mesh/register | POST /api/v1/mesh/heartbeat | GET /api/v1/mesh/nodes (signed) |',
2220
+ ' GET /api/v1/admin/{overview,users,nodes,sandboxes,audit} (admin)',
2221
+ `sandbox ttl: ${ttlms / 60000} min, sweep every ${sweepperiodms / 1000} s`,
2222
+ `sandbox workspace: persistent per sandbox id, ${store.sandboxquota()} byte quota (SADDLE_SANDBOX_QUOTA_BYTES, default 16 MiB)`,
2223
+ `sandbox expiry keeps workspace files (self-contained); manual DELETE purges them${workspacepersist() ? ' [SADDLE_SANDBOX_PERSIST=true]' : ''}`,
2224
+ ].join('\n') + '\n');
2225
+ });
2226
+ export { server, sandboxes, createsandbox, handleapi };