@wenathlan/saddle 2.0.150 → 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.
- package/Dockerfile +43 -23
- package/README.md +4 -4
- package/biome.jsonc +134 -0
- package/dist/App.d.ts +3 -0
- package/dist/App.js +50 -0
- package/dist/Sol/Sol.d.ts +3 -0
- package/dist/Sol/Sol.js +46 -0
- package/dist/Sol/agentbrowser/agentbrowser.d.ts +9 -0
- package/dist/Sol/agentbrowser/agentbrowser.js +121 -0
- package/dist/Sol/architecture/architecture.d.ts +9 -0
- package/dist/Sol/architecture/architecture.js +93 -0
- package/dist/Sol/compute/compute.d.ts +9 -0
- package/dist/Sol/compute/compute.js +22 -0
- package/dist/Sol/console/console.d.ts +15 -0
- package/dist/Sol/console/console.js +292 -0
- package/dist/Sol/dashboard/dashboard.d.ts +10 -0
- package/dist/Sol/dashboard/dashboard.js +181 -0
- package/dist/Sol/docs/docs.d.ts +9 -0
- package/dist/Sol/docs/docs.js +28 -0
- package/dist/Sol/home/home.d.ts +35 -0
- package/dist/Sol/home/home.js +239 -0
- package/dist/Sol/integrations/integrations.d.ts +9 -0
- package/dist/Sol/integrations/integrations.js +83 -0
- package/dist/Sol/intro/intro.d.ts +7 -0
- package/dist/Sol/intro/intro.js +102 -0
- package/dist/Sol/login/login.d.ts +16 -0
- package/dist/Sol/login/login.js +49 -0
- package/dist/Sol/notfound/notfound.d.ts +9 -0
- package/dist/Sol/notfound/notfound.js +15 -0
- package/dist/Sol/onboarding/onboarding.d.ts +25 -0
- package/dist/Sol/onboarding/onboarding.js +60 -0
- package/dist/Sol/playground/playground.d.ts +9 -0
- package/dist/Sol/playground/playground.js +127 -0
- package/dist/Sol/register/register.d.ts +16 -0
- package/dist/Sol/register/register.js +193 -0
- package/dist/Sol/shell/Shell.d.ts +124 -0
- package/dist/Sol/shell/Shell.js +335 -0
- package/dist/Sol/wingets/ComputeBootladder.d.ts +6 -0
- package/dist/Sol/wingets/ComputeBootladder.js +71 -0
- package/dist/Sol/wingets/ComputeModalitycatalog.d.ts +7 -0
- package/dist/Sol/wingets/ComputeModalitycatalog.js +71 -0
- package/dist/Sol/wingets/ConsoleApibadge.d.ts +7 -0
- package/dist/Sol/wingets/ConsoleApibadge.js +5 -0
- package/dist/Sol/wingets/ConsoleEdgelaw.d.ts +6 -0
- package/dist/Sol/wingets/ConsoleEdgelaw.js +36 -0
- package/dist/Sol/wingets/ConsoleEventstimeline.d.ts +19 -0
- package/dist/Sol/wingets/ConsoleEventstimeline.js +12 -0
- package/dist/Sol/wingets/ConsoleSnapshotcontrols.d.ts +8 -0
- package/dist/Sol/wingets/ConsoleSnapshotcontrols.js +8 -0
- package/dist/Sol/wingets/ConsoleSpecpanel.d.ts +21 -0
- package/dist/Sol/wingets/ConsoleSpecpanel.js +75 -0
- package/dist/Sol/wingets/ConsoleTerminal.d.ts +22 -0
- package/dist/Sol/wingets/ConsoleTerminal.js +77 -0
- package/dist/Sol/wingets/DashboardAccountpanel.d.ts +18 -0
- package/dist/Sol/wingets/DashboardAccountpanel.js +65 -0
- package/dist/Sol/wingets/DashboardAdminpanel.d.ts +6 -0
- package/dist/Sol/wingets/DashboardAdminpanel.js +90 -0
- package/dist/Sol/wingets/DashboardAuditlog.d.ts +8 -0
- package/dist/Sol/wingets/DashboardAuditlog.js +50 -0
- package/dist/Sol/wingets/DashboardCreatesandboxform.d.ts +10 -0
- package/dist/Sol/wingets/DashboardCreatesandboxform.js +71 -0
- package/dist/Sol/wingets/DashboardEventstimeline.d.ts +8 -0
- package/dist/Sol/wingets/DashboardEventstimeline.js +83 -0
- package/dist/Sol/wingets/DashboardGlobalsandboxestable.d.ts +8 -0
- package/dist/Sol/wingets/DashboardGlobalsandboxestable.js +57 -0
- package/dist/Sol/wingets/DashboardMeshnodestable.d.ts +8 -0
- package/dist/Sol/wingets/DashboardMeshnodestable.js +103 -0
- package/dist/Sol/wingets/DashboardSandboxlist.d.ts +10 -0
- package/dist/Sol/wingets/DashboardSandboxlist.js +163 -0
- package/dist/Sol/wingets/DashboardUserstable.d.ts +8 -0
- package/dist/Sol/wingets/DashboardUserstable.js +78 -0
- package/dist/Sol/wingets/HomeEntrygate.d.ts +23 -0
- package/dist/Sol/wingets/HomeEntrygate.js +73 -0
- package/dist/Sol/wingets/HomeMetricstrip.d.ts +9 -0
- package/dist/Sol/wingets/HomeMetricstrip.js +4 -0
- package/dist/Sol/wingets/HomeRuntimediagram.d.ts +1 -0
- package/dist/Sol/wingets/HomeRuntimediagram.js +22 -0
- package/dist/Sol/wingets/LoginForm.d.ts +19 -0
- package/dist/Sol/wingets/LoginForm.js +141 -0
- package/dist/Sol/wingets/RegisterPasswordconfirm.d.ts +13 -0
- package/dist/Sol/wingets/RegisterPasswordconfirm.js +16 -0
- package/dist/Sol/wingets/RegisterPasswordmeter.d.ts +19 -0
- package/dist/Sol/wingets/RegisterPasswordmeter.js +65 -0
- package/dist/Sol/wingets/RegisterUsernamerules.d.ts +11 -0
- package/dist/Sol/wingets/RegisterUsernamerules.js +16 -0
- package/dist/Sol/wingets/index.d.ts +39 -0
- package/dist/Sol/wingets/index.js +39 -0
- package/dist/alternatives.d.ts +508 -0
- package/dist/alternatives.js +950 -0
- package/dist/api.d.ts +154 -0
- package/dist/api.js +174 -0
- package/dist/auth.d.ts +268 -0
- package/dist/auth.js +476 -0
- package/dist/automation.d.ts +425 -0
- package/dist/automation.js +566 -0
- package/dist/browser.d.ts +561 -0
- package/dist/browser.js +1058 -0
- package/dist/capacitor.config.d.ts +45 -0
- package/dist/capacitor.config.js +48 -0
- package/dist/catalog.d.ts +43 -0
- package/dist/catalog.js +44 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +168 -0
- package/dist/communication.d.ts +181 -0
- package/dist/communication.js +376 -0
- package/dist/compute.d.ts +1160 -0
- package/dist/compute.js +2272 -0
- package/dist/dashboardlib.d.ts +58 -0
- package/dist/dashboardlib.js +161 -0
- package/dist/db.d.ts +569 -0
- package/dist/db.js +1080 -0
- package/dist/distribution.d.ts +528 -0
- package/dist/distribution.js +810 -0
- package/dist/drizzle.config.d.ts +20 -0
- package/dist/drizzle.config.js +18 -0
- package/dist/edgelandes.d.ts +70 -0
- package/dist/edgelandes.host.d.ts +26 -0
- package/dist/edgelandes.host.js +1089 -0
- package/dist/edgelandes.inproc.d.ts +27 -0
- package/dist/edgelandes.inproc.js +952 -0
- package/dist/edgelandes.js +1133 -0
- package/dist/edgelandes.ninety.d.ts +31 -0
- package/dist/edgelandes.ninety.js +174 -0
- package/dist/edgelandes.virtual.d.ts +25 -0
- package/dist/edgelandes.virtual.js +629 -0
- package/dist/edgeplane.d.ts +114 -0
- package/dist/edgeplane.js +305 -0
- package/dist/examplelocaljob.d.ts +1 -0
- package/{docs/example-localjob.ts → dist/examplelocaljob.js} +0 -1
- package/dist/execution.d.ts +529 -0
- package/dist/execution.js +760 -0
- package/dist/family.d.ts +16 -0
- package/dist/family.js +42 -0
- package/dist/familyurl.d.ts +21 -0
- package/dist/familyurl.js +24 -0
- package/dist/format.d.ts +18 -0
- package/dist/format.js +59 -0
- package/dist/foundation.d.ts +172 -0
- package/dist/foundation.js +312 -0
- package/dist/index.d.ts +735 -0
- package/dist/index.js +1020 -0
- package/dist/integration.d.ts +235 -0
- package/dist/integration.js +228 -0
- package/dist/intelligence.d.ts +93 -0
- package/dist/intelligence.js +144 -0
- package/dist/isolation.d.ts +128 -0
- package/dist/isolation.js +95 -0
- package/dist/labels.d.ts +665 -0
- package/dist/labels.js +751 -0
- package/dist/localauth.d.ts +127 -0
- package/dist/localauth.js +413 -0
- package/dist/media.d.ts +343 -0
- package/dist/media.js +985 -0
- package/dist/mesh.d.ts +177 -0
- package/dist/mesh.js +468 -0
- package/dist/modalityregistry.d.ts +109 -0
- package/dist/modalityregistry.js +1386 -0
- package/dist/modes.d.ts +358 -0
- package/dist/modes.js +133 -0
- package/dist/onboardingstate.d.ts +79 -0
- package/dist/onboardingstate.js +282 -0
- package/dist/orchestrator.d.ts +1338 -0
- package/dist/orchestrator.js +4310 -0
- package/dist/packager/targetcli.js +4 -0
- package/dist/paths.d.ts +2 -0
- package/dist/paths.js +14 -0
- package/dist/performance.d.ts +415 -0
- package/dist/performance.js +826 -0
- package/dist/quantum.d.ts +809 -0
- package/dist/quantum.js +2072 -0
- package/dist/render.d.ts +380 -0
- package/dist/render.js +1072 -0
- package/dist/runall.d.ts +2 -0
- package/dist/runall.js +39 -0
- package/dist/runtimeprobe.d.ts +4 -0
- package/dist/runtimeprobe.js +8 -0
- package/dist/sandbox.d.ts +355 -0
- package/dist/sandbox.js +1721 -0
- package/dist/sandboxenvprofile.d.ts +29 -0
- package/dist/sandboxenvprofile.js +89 -0
- package/dist/sandboxprofile.d.ts +137 -0
- package/dist/sandboxprofile.js +241 -0
- package/dist/sandboxqueue.d.ts +108 -0
- package/dist/sandboxqueue.js +201 -0
- package/dist/sandboxquota.d.ts +121 -0
- package/dist/sandboxquota.js +236 -0
- package/dist/scheduler.d.ts +595 -0
- package/dist/scheduler.js +1258 -0
- package/dist/scriptlanes.d.ts +182 -0
- package/dist/scriptlanes.js +660 -0
- package/dist/scripts.buildengine.d.ts +1 -0
- package/dist/scripts.buildengine.js +39 -0
- package/dist/scripts.securitygate.d.ts +1 -0
- package/dist/scripts.securitygate.js +68 -0
- package/dist/scripts.validateflatnative.d.ts +1 -0
- package/dist/scripts.validateflatnative.js +72 -0
- package/dist/security.d.ts +500 -0
- package/dist/security.js +1169 -0
- package/dist/seedcatalog.d.ts +13 -0
- package/dist/seedcatalog.js +46 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.js +53 -0
- package/dist/speccatalog.d.ts +31 -0
- package/dist/speccatalog.js +163 -0
- package/dist/tiers.d.ts +1001 -0
- package/dist/tiers.js +2169 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +5 -0
- package/dist/vaulthandoff.d.ts +141 -0
- package/dist/vaulthandoff.js +444 -0
- package/dist/virtual.d.ts +640 -0
- package/dist/virtual.js +1284 -0
- package/dist/virtualcpu.d.ts +315 -0
- package/dist/virtualcpu.js +1180 -0
- package/dist/virtualgpu.d.ts +344 -0
- package/dist/virtualgpu.js +1000 -0
- package/dist/virtualization.d.ts +606 -0
- package/dist/virtualization.js +1186 -0
- package/dist/virtualmemory.d.ts +817 -0
- package/dist/virtualmemory.js +1795 -0
- package/dist/vite.config.d.ts +3 -0
- package/dist/vite.config.js +43 -0
- package/dist/vitest.config.d.ts +2 -0
- package/dist/vitest.config.js +25 -0
- package/dist/webserver.d.ts +166 -0
- package/dist/webserver.js +2226 -0
- package/dist/workloadlaw.d.ts +133 -0
- package/dist/workloadlaw.js +193 -0
- package/docker.config +13 -6
- package/docs/artifactavailability.md +2 -2
- package/docs/{containerplatforms-2.1.0.md → containerplatforms.2.1.0.md} +1 -1
- package/docs/{documentmap-1.8.12.md → documentmap.1.8.12.md} +1 -1
- package/docs/ninety.modalities.md +108 -0
- package/docs/planning.ninety.md +391 -0
- package/docs/{releasenotes-1.8.13.md → releasenotes.1.8.13.md} +2 -2
- package/docs/{releasenotes-1.8.14.md → releasenotes.1.8.14.md} +1 -1
- package/docs/{repo-synthesis-1.8.16.md → repo.synthesis.1.8.16.md} +1 -1
- package/docs/{todo-1.8.15.md → todo.1.8.15.md} +1 -1
- package/docs/{todo-1.8.16.md → todo.1.8.16.md} +5 -5
- package/docs/todo.md +2 -2
- package/docs/workloadlaw.md +104 -0
- package/index.ts +4 -3
- package/manifest.json +1 -1
- package/modalityregistry.ts +1493 -0
- package/orchestrator.ts +1 -1
- package/package.json +76 -37
- package/docs/CONVERSA.txt +0 -1523
- package/docs/architecture-1.8.19.png +0 -0
- package/docs/assets/architecture.svg +0 -45
- package/docs/assets/saddle-browser-trace.webp +0 -0
- package/docs/assets/saddle-hero-bridge.webp +0 -0
- package/docs/assets/saddle-mark.webp +0 -0
- package/docs/assets/saddle-runtime-map.webp +0 -0
- package/docs/assets/saddlemark.svg +0 -13
- package/docs/example-publicapi.ts +0 -7
- package/docs/saddle.archive.1.8.17.tar.gz.gpg +0 -0
- /package/docs/{architecture-1.8.19.md → architecture.1.8.19.md} +0 -0
- /package/docs/{architecture-1.8.19.mmd → architecture.1.8.19.mmd} +0 -0
- /package/docs/{artifactresearch-1.8.9.md → artifactresearch.1.8.9.md} +0 -0
- /package/docs/{batches-1816-1820.md → batches.1816.to.1820.md} +0 -0
- /package/docs/{brancharchive-2026-08-18.md → brancharchive.2026.08.18.md} +0 -0
- /package/docs/{containerplatforms-1.8.17.md → containerplatforms.1.8.17.md} +0 -0
- /package/docs/{e2ugh-engine.md → e2ugh.engine.md} +0 -0
- /package/docs/{flatbuildresearch-1.8.11.md → flatbuildresearch.1.8.11.md} +0 -0
- /package/docs/{licenseaudit-1.8.12.md → licenseaudit.1.8.12.md} +0 -0
- /package/docs/{mobileconversionresearch-1.8.10.md → mobileconversionresearch.1.8.10.md} +0 -0
- /package/docs/{native-wrappers.md → native.wrappers.md} +0 -0
- /package/docs/{nativeidentityresearch-1.8.11.md → nativeidentityresearch.1.8.11.md} +0 -0
- /package/docs/{releasenotes-1.8.12.md → releasenotes.1.8.12.md} +0 -0
- /package/docs/{releasenotes-1.8.15.md → releasenotes.1.8.15.md} +0 -0
- /package/docs/{releasenotes-1.8.16.md → releasenotes.1.8.16.md} +0 -0
- /package/docs/{releasenotes-1.8.17.md → releasenotes.1.8.17.md} +0 -0
- /package/docs/{releasenotes-1.8.18.md → releasenotes.1.8.18.md} +0 -0
- /package/docs/{releasenotes-1.8.19.md → releasenotes.1.8.19.md} +0 -0
- /package/docs/{reorganization-1.8.8.md → reorganization.1.8.8.md} +0 -0
- /package/docs/{reorganization-1.8.9.md → reorganization.1.8.9.md} +0 -0
- /package/docs/{repo-analysis-1.8.21.json → repo.analysis.1.8.21.json} +0 -0
- /package/docs/{repo-candidates-1.8.21.jsonl → repo.candidates.1.8.21.jsonl} +0 -0
- /package/docs/{repo-research-1.8.16.md → repo.research.1.8.16.md} +0 -0
- /package/docs/{repo-research-1.8.21.md → repo.research.1.8.21.md} +0 -0
- /package/docs/{repo-selected-1.8.21.json → repo.selected.1.8.21.json} +0 -0
- /package/docs/{repo-synthesis-1.8.21.md → repo.synthesis.1.8.21.md} +0 -0
- /package/docs/{research-1.8.18-isolation.md → research.1.8.18.isolation.md} +0 -0
- /package/docs/{research-1.8.19-virtual-browser.md → research.1.8.19.virtual.browser.md} +0 -0
- /package/docs/{scanningresearch-1.8.11.md → scanningresearch.1.8.11.md} +0 -0
- /package/docs/{securityaudit-1.8.7.md → securityaudit.1.8.7.md} +0 -0
- /package/docs/{securityreferences-1.8.11.md → securityreferences.1.8.11.md} +0 -0
- /package/docs/{securityresearch-1.8.11.md → securityresearch.1.8.11.md} +0 -0
- /package/docs/{signingresearch-1.8.12.md → signingresearch.1.8.12.md} +0 -0
- /package/docs/{tauri-glib-risk-1.8.11.md → tauri.glib.risk.1.8.11.md} +0 -0
- /package/docs/{todo-1.8.18.md → todo.1.8.18.md} +0 -0
- /package/docs/{todo-1.8.19.md → todo.1.8.19.md} +0 -0
- /package/docs/{toolchainresearch-1.8.9.md → toolchainresearch.1.8.9.md} +0 -0
- /package/docs/{web-duplicatecleanup.md → web.duplicatecleanup.md} +0 -0
- /package/docs/{web-ideas.md → web.ideas.md} +0 -0
- /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 };
|