@wenathlan/saddle 2.0.151 → 2.0.153
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/biome.jsonc +12 -5
- package/dist/App.d.ts +5 -0
- package/dist/App.js +52 -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/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 +1 -1
- package/manifest.json +1 -1
- package/modalityregistry.ts +1371 -34
- package/orchestrator.ts +1 -1
- package/package.json +78 -33
- 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/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
package/dist/auth.js
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auth.ts — security layer for the saddle web node (v7-BACK).
|
|
3
|
+
*
|
|
4
|
+
* everything that touches credentials and sessions lives here: scrypt
|
|
5
|
+
* password hashing (n=16384, r=8, p=1) with timing-safe verification,
|
|
6
|
+
* opaque base64url session tokens stored only as sha256 hashes, the
|
|
7
|
+
* saddlesession cookie builder, the in-memory ip rate limiter, and the
|
|
8
|
+
* requireauth/requireadmin request guards. the bootstrap rule is part
|
|
9
|
+
* of the contract: the first user registered on a node with zero users
|
|
10
|
+
* is granted role admin (see createsession caller in server.ts), so a
|
|
11
|
+
* fresh main or standalone node always has one operator.
|
|
12
|
+
*
|
|
13
|
+
* contexts (9): passwordhashing, sessiontokens, sessionstore, cookies,
|
|
14
|
+
* ratelimiter, requestguards, usernamepolicy, timingequalizer,
|
|
15
|
+
* bootstrap.
|
|
16
|
+
*
|
|
17
|
+
* rules: lowercase identifiers, english jsdoc in third person, no emoji,
|
|
18
|
+
* try/catch on every fallible path, node:* modules only, zero
|
|
19
|
+
* dependencies, no hardcoded localhost anywhere.
|
|
20
|
+
*/
|
|
21
|
+
import { createHash, randomBytes, scryptSync, timingSafeEqual, } from 'node:crypto';
|
|
22
|
+
import store from './db.ts';
|
|
23
|
+
/* ------------------------------------------------------------------ */
|
|
24
|
+
/* context: passwordhashing */
|
|
25
|
+
/* ------------------------------------------------------------------ */
|
|
26
|
+
/** scrypt profile pinned by the v7 contract: n=16384, r=8, p=1. */
|
|
27
|
+
const scryptoptions = { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
|
|
28
|
+
/** derived key length in bytes (hex digest of 128 characters). */
|
|
29
|
+
const keylen = 64;
|
|
30
|
+
/** random salt length in bytes (hex digest of 64 characters). */
|
|
31
|
+
const saltlen = 32;
|
|
32
|
+
/**
|
|
33
|
+
* hashes one password with scrypt and a fresh 32-byte random salt.
|
|
34
|
+
*
|
|
35
|
+
* @param password the plaintext password (8-128 chars,
|
|
36
|
+
* enforced by the route layer).
|
|
37
|
+
* @returns the hex digest and the hex salt, both ready for the users row.
|
|
38
|
+
*/
|
|
39
|
+
export function hashpassword(password) {
|
|
40
|
+
const salt = randomBytes(saltlen);
|
|
41
|
+
const passwordhash = scryptSync(String(password), salt, keylen, scryptoptions).toString('hex');
|
|
42
|
+
return { passwordhash, salt: salt.toString('hex') };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* verifies one password against a stored hash and salt using
|
|
46
|
+
* timingSafeEqual; any input or encoding failure resolves to false so
|
|
47
|
+
* callers never leak the failure reason.
|
|
48
|
+
*
|
|
49
|
+
* @param password the plaintext candidate.
|
|
50
|
+
* @param passwordhash the stored hex digest.
|
|
51
|
+
* @param salt the stored hex salt.
|
|
52
|
+
* @returns true when the password matches.
|
|
53
|
+
*/
|
|
54
|
+
export function verifypassword(password, passwordhash, salt) {
|
|
55
|
+
try {
|
|
56
|
+
const stored = Buffer.from(String(passwordhash), 'hex');
|
|
57
|
+
const computed = scryptSync(String(password), Buffer.from(String(salt), 'hex'), keylen, scryptoptions);
|
|
58
|
+
if (stored.length !== computed.length) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
return timingSafeEqual(stored, computed);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** a constant dummy pair used to equalize login timing for unknown users. */
|
|
68
|
+
const dummyhash = 'a3f1c0d59e7b4286a1f02e9b8d7c65f3e2b1a0987d6c5b4a39281736f5e4d3c2b1a0f9e8d7c6b5a4938271605f4e3d2c1b0a998877665544332211';
|
|
69
|
+
const dummysalt = '0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0';
|
|
70
|
+
/**
|
|
71
|
+
* burns one scrypt round against the dummy pair so a login for an
|
|
72
|
+
* unknown username costs the same time as a login for a real one.
|
|
73
|
+
*
|
|
74
|
+
* @returns always false.
|
|
75
|
+
*/
|
|
76
|
+
function burndummy() {
|
|
77
|
+
verifypassword('saddle-timing-equalizer', dummyhash, dummysalt);
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
/* ------------------------------------------------------------------ */
|
|
81
|
+
/* context: sessiontokens and sessionstore */
|
|
82
|
+
/* ------------------------------------------------------------------ */
|
|
83
|
+
/** session lifetime: 24 hours in milliseconds. */
|
|
84
|
+
const sessionttlms = 24 * 60 * 60 * 1000;
|
|
85
|
+
/**
|
|
86
|
+
* mints one opaque session token: 32 random bytes in base64url.
|
|
87
|
+
*
|
|
88
|
+
* @returns the bearer/cookie token.
|
|
89
|
+
*/
|
|
90
|
+
export function sessiontoken() {
|
|
91
|
+
return randomBytes(32).toString('base64url');
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* derives the sha256 hex digest stored in the sessions table; the raw
|
|
95
|
+
* token never touches the database.
|
|
96
|
+
*
|
|
97
|
+
* @param token the raw session token.
|
|
98
|
+
* @returns the hex token hash.
|
|
99
|
+
*/
|
|
100
|
+
export function tokenhash(token) {
|
|
101
|
+
return createHash('sha256').update(String(token)).digest('hex');
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* creates one session row for a user and returns the token material.
|
|
105
|
+
*
|
|
106
|
+
* @param userid the user id.
|
|
107
|
+
* @param context the request context and an optional ttl override.
|
|
108
|
+
* @returns the minted session values.
|
|
109
|
+
*/
|
|
110
|
+
export function createsession(userid, context = {}) {
|
|
111
|
+
const token = sessiontoken();
|
|
112
|
+
const hash = tokenhash(token);
|
|
113
|
+
const expiresat = new Date(Date.now() + (context.ttlms ?? sessionttlms)).toISOString();
|
|
114
|
+
store.createsession({
|
|
115
|
+
tokenhash: hash,
|
|
116
|
+
userid,
|
|
117
|
+
expiresat,
|
|
118
|
+
ip: context.ip,
|
|
119
|
+
useragent: context.useragent,
|
|
120
|
+
});
|
|
121
|
+
return { token, tokenhash: hash, expiresat };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* caches one session minted elsewhere (the mesh main node) into the
|
|
125
|
+
* local sessions table with the remote userid.
|
|
126
|
+
*
|
|
127
|
+
* @param fields the forwarded session.
|
|
128
|
+
* @returns void.
|
|
129
|
+
*/
|
|
130
|
+
export function cachesession(fields) {
|
|
131
|
+
store.createsession({
|
|
132
|
+
tokenhash: tokenhash(fields.token),
|
|
133
|
+
userid: fields.userid,
|
|
134
|
+
expiresat: fields.expiresat,
|
|
135
|
+
ip: fields.ip,
|
|
136
|
+
useragent: fields.useragent,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* resolves the user behind one raw token: hash lookup, expiry check and
|
|
141
|
+
* user projection in one call.
|
|
142
|
+
*
|
|
143
|
+
* @param token the raw session token.
|
|
144
|
+
* @returns the user or null when the session is
|
|
145
|
+
* missing, expired or orphaned.
|
|
146
|
+
*/
|
|
147
|
+
export function getsessionuser(token) {
|
|
148
|
+
try {
|
|
149
|
+
const session = store.findsession(tokenhash(String(token)));
|
|
150
|
+
if (session === null) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
if (typeof session.expiresat !== 'string' || session.expiresat <= new Date().toISOString()) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const user = store.finduserbyid(session.userid);
|
|
157
|
+
if (user === null) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
id: user.id,
|
|
162
|
+
username: user.username,
|
|
163
|
+
role: user.role,
|
|
164
|
+
expiresat: session.expiresat,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* destroys one session by raw token.
|
|
173
|
+
*
|
|
174
|
+
* @param token the raw session token.
|
|
175
|
+
* @returns true when a session row was removed.
|
|
176
|
+
*/
|
|
177
|
+
export function destroysession(token) {
|
|
178
|
+
try {
|
|
179
|
+
return store.deletesession(tokenhash(String(token)));
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/* ------------------------------------------------------------------ */
|
|
186
|
+
/* context: cookies */
|
|
187
|
+
/* ------------------------------------------------------------------ */
|
|
188
|
+
/** the session cookie name shared by every node in the mesh. */
|
|
189
|
+
export const cookiename = 'saddlesession';
|
|
190
|
+
/** the cookie max-age in seconds (24 hours). */
|
|
191
|
+
const cookiemaxage = 86400;
|
|
192
|
+
/**
|
|
193
|
+
* builds the set-cookie value for one session token: HttpOnly,
|
|
194
|
+
* SameSite=Strict, Path=/, Max-Age=86400 and Secure whenever the
|
|
195
|
+
* request arrived through https (directly or via x-forwarded-proto).
|
|
196
|
+
*
|
|
197
|
+
* @param token the raw session token.
|
|
198
|
+
* @param req the incoming request, used only to detect https.
|
|
199
|
+
* @returns the set-cookie header value.
|
|
200
|
+
*/
|
|
201
|
+
export function cookiefor(token, req) {
|
|
202
|
+
const forwarded = req?.headers?.['x-forwarded-proto'];
|
|
203
|
+
const secure = forwarded === 'https' ? '; Secure' : '';
|
|
204
|
+
return `${cookiename}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookiemaxage}${secure}`;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* builds the expiring set-cookie value used by logout.
|
|
208
|
+
*
|
|
209
|
+
* @returns the set-cookie header value clearing the cookie.
|
|
210
|
+
*/
|
|
211
|
+
export function cookieclear() {
|
|
212
|
+
return `${cookiename}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* extracts the session token from the saddlesession cookie or the
|
|
216
|
+
* Authorization: Bearer header of one request.
|
|
217
|
+
*
|
|
218
|
+
* @param req the incoming request.
|
|
219
|
+
* @returns the raw token or null.
|
|
220
|
+
*/
|
|
221
|
+
export function extracttoken(req) {
|
|
222
|
+
try {
|
|
223
|
+
const header = req.headers?.authorization;
|
|
224
|
+
if (typeof header === 'string' && header.startsWith('Bearer ')) {
|
|
225
|
+
const bearer = header.slice('Bearer '.length).trim();
|
|
226
|
+
if (bearer.length > 0) {
|
|
227
|
+
return bearer;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const cookieheader = req.headers?.cookie;
|
|
231
|
+
if (typeof cookieheader === 'string' && cookieheader.length > 0) {
|
|
232
|
+
for (const part of cookieheader.split(';')) {
|
|
233
|
+
const separator = part.indexOf('=');
|
|
234
|
+
if (separator === -1) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const name = part.slice(0, separator).trim();
|
|
238
|
+
if (name === cookiename) {
|
|
239
|
+
const value = part.slice(separator + 1).trim();
|
|
240
|
+
if (value.length > 0) {
|
|
241
|
+
return value;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/* ------------------------------------------------------------------ */
|
|
253
|
+
/* context: requestguards */
|
|
254
|
+
/* ------------------------------------------------------------------ */
|
|
255
|
+
/**
|
|
256
|
+
* the auth guard: reads the cookie or bearer token, resolves the
|
|
257
|
+
* session and injects req.user = {id, username, role, expiresat}.
|
|
258
|
+
*
|
|
259
|
+
* @param req the incoming request.
|
|
260
|
+
* @returns the injected user or null when
|
|
261
|
+
* unauthenticated.
|
|
262
|
+
*/
|
|
263
|
+
export function requireauth(req) {
|
|
264
|
+
const token = extracttoken(req);
|
|
265
|
+
if (token === null) {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
const user = getsessionuser(token);
|
|
269
|
+
if (user === null) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
req.user = user;
|
|
273
|
+
req.sessiontoken = token;
|
|
274
|
+
return user;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* the admin guard: requireauth plus the admin role check.
|
|
278
|
+
*
|
|
279
|
+
* @param req the incoming request.
|
|
280
|
+
* @returns the admin user or null.
|
|
281
|
+
*/
|
|
282
|
+
export function requireadmin(req) {
|
|
283
|
+
const user = requireauth(req);
|
|
284
|
+
if (user === null || user.role !== 'admin') {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
return user;
|
|
288
|
+
}
|
|
289
|
+
/* ------------------------------------------------------------------ */
|
|
290
|
+
/* context: usernamepolicy */
|
|
291
|
+
/* ------------------------------------------------------------------ */
|
|
292
|
+
/** usernames that can never be registered. */
|
|
293
|
+
const reservednames = new Set(['admin', 'root', 'system']);
|
|
294
|
+
/** the username shape: 3-32 chars of a-z 0-9 dot dash. */
|
|
295
|
+
const usernamepattern = /^[a-z0-9.-]{3,32}$/;
|
|
296
|
+
/**
|
|
297
|
+
* validates one username candidate against the v7 policy; the candidate
|
|
298
|
+
* is coerced defensively so route layers can pass raw json fields.
|
|
299
|
+
*
|
|
300
|
+
* @param username the candidate.
|
|
301
|
+
* @returns the validation outcome.
|
|
302
|
+
*/
|
|
303
|
+
export function validateusername(username) {
|
|
304
|
+
const value = String(username ?? '');
|
|
305
|
+
if (usernamepattern.test(value) !== true) {
|
|
306
|
+
return {
|
|
307
|
+
ok: false,
|
|
308
|
+
code: 'invalid-username',
|
|
309
|
+
message: 'username must be 3-32 characters of a-z, 0-9, dot or dash',
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
if (reservednames.has(value)) {
|
|
313
|
+
return {
|
|
314
|
+
ok: false,
|
|
315
|
+
code: 'username-reserved',
|
|
316
|
+
message: 'username is reserved',
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return { ok: true };
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* validates one password candidate against the v7 policy (8-128 chars);
|
|
323
|
+
* the candidate is coerced defensively so route layers can pass raw json
|
|
324
|
+
* fields.
|
|
325
|
+
*
|
|
326
|
+
* @param password the candidate.
|
|
327
|
+
* @returns the validation outcome.
|
|
328
|
+
*/
|
|
329
|
+
export function validatepassword(password) {
|
|
330
|
+
const value = String(password ?? '');
|
|
331
|
+
if (value.length < 8 || value.length > 128) {
|
|
332
|
+
return {
|
|
333
|
+
ok: false,
|
|
334
|
+
code: 'invalid-password',
|
|
335
|
+
message: 'password must be between 8 and 128 characters',
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
return { ok: true };
|
|
339
|
+
}
|
|
340
|
+
/* ------------------------------------------------------------------ */
|
|
341
|
+
/* context: ratelimiter */
|
|
342
|
+
/* ------------------------------------------------------------------ */
|
|
343
|
+
/** in-memory buckets: key -> {count, resetat}. */
|
|
344
|
+
const buckets = new Map();
|
|
345
|
+
/** the sweep runs whenever the map grows past this size. */
|
|
346
|
+
const sweeplimit = 4096;
|
|
347
|
+
/**
|
|
348
|
+
* the in-memory rate limiter with lazy sweeping: every hit refreshes
|
|
349
|
+
* its own window and, once the map grows past the sweep limit, expired
|
|
350
|
+
* buckets from other keys are dropped.
|
|
351
|
+
*
|
|
352
|
+
* @param bucketkey the bucket key (e.g. `login:10.0.0.1`).
|
|
353
|
+
* @param limit the allowed hits per window.
|
|
354
|
+
* @param windowms the window length in milliseconds.
|
|
355
|
+
* @returns the verdict plus the retry-after seconds for 429 responses.
|
|
356
|
+
*/
|
|
357
|
+
export function ratelimit(bucketkey, limit, windowms) {
|
|
358
|
+
try {
|
|
359
|
+
const now = Date.now();
|
|
360
|
+
if (buckets.size > sweeplimit) {
|
|
361
|
+
for (const [key, bucket] of buckets) {
|
|
362
|
+
if (bucket.resetat <= now) {
|
|
363
|
+
buckets.delete(key);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const existing = buckets.get(bucketkey);
|
|
368
|
+
if (existing === undefined || existing.resetat <= now) {
|
|
369
|
+
buckets.set(bucketkey, { count: 1, resetat: now + windowms });
|
|
370
|
+
return { allowed: true, retryafter: 0 };
|
|
371
|
+
}
|
|
372
|
+
existing.count += 1;
|
|
373
|
+
const retryafter = Math.max(1, Math.ceil((existing.resetat - now) / 1000));
|
|
374
|
+
return { allowed: existing.count <= limit, retryafter };
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
/* a limiter failure never blocks traffic */
|
|
378
|
+
return { allowed: true, retryafter: 0 };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/** the two pinned buckets from the v7 contract. */
|
|
382
|
+
export const ratelimits = {
|
|
383
|
+
/** register: 10 attempts per hour per ip. */
|
|
384
|
+
register: { limit: 10, windowms: 60 * 60 * 1000 },
|
|
385
|
+
/** login: 10 attempts per minute per ip. */
|
|
386
|
+
login: { limit: 10, windowms: 60 * 1000 },
|
|
387
|
+
};
|
|
388
|
+
/* ------------------------------------------------------------------ */
|
|
389
|
+
/* context: bootstrap */
|
|
390
|
+
/* ------------------------------------------------------------------ */
|
|
391
|
+
/**
|
|
392
|
+
* the ONLY admin usernames: the CODEOWNERS allowlist (iakadion,
|
|
393
|
+
* inathlan, aasblor and nasblor). the web admin surface mirrors the
|
|
394
|
+
* repository CODEOWNERS file - the two files are the same contract.
|
|
395
|
+
*/
|
|
396
|
+
export const adminusernames = ['iakadion', 'inathlan', 'aasblor', 'nasblor'];
|
|
397
|
+
/**
|
|
398
|
+
* the env key the operator or the pipeline pins the admin seed password
|
|
399
|
+
* with - the server twin of the VITE_SADDLE_ADMIN_SEED_PASSWORD the static
|
|
400
|
+
* edge rides (localauth.ts): the public repository carries no credential.
|
|
401
|
+
*/
|
|
402
|
+
const adminseedenv = 'SADDLE_ADMIN_SEED_PASSWORD';
|
|
403
|
+
/**
|
|
404
|
+
* resolves the bootstrap password of the seeded admin accounts. the
|
|
405
|
+
* operator pins it through SADDLE_ADMIN_SEED_PASSWORD (the pipeline
|
|
406
|
+
* secret path); a bare boot without the env mints one strong random
|
|
407
|
+
* secret for the process - the public source never carries a credential
|
|
408
|
+
* (the hardcoded-seed hole closed), the seeded rows still exist before
|
|
409
|
+
* anyone registers (nobody can squat a CODEOWNERS name), and the minted
|
|
410
|
+
* secret is surfaced once on the operator console by seedadmins so the
|
|
411
|
+
* bootstrap account stays reachable.
|
|
412
|
+
*
|
|
413
|
+
* @returns the password to seed with and whether it was minted for this boot.
|
|
414
|
+
*/
|
|
415
|
+
function resolveadminseed() {
|
|
416
|
+
const pinned = process.env[adminseedenv];
|
|
417
|
+
if (typeof pinned === 'string' && pinned.length > 0) {
|
|
418
|
+
return { password: pinned, minted: false };
|
|
419
|
+
}
|
|
420
|
+
return { password: randomBytes(18).toString('base64url'), minted: true };
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* resolves the role for a new registration: only the CODEOWNERS
|
|
424
|
+
* usernames are admins; every other account (including the very first
|
|
425
|
+
* registration) is a plain user. the seeded admins below guarantee the
|
|
426
|
+
* allowlist accounts exist before anyone registers.
|
|
427
|
+
*
|
|
428
|
+
* @param username the requested username.
|
|
429
|
+
* @returns the role for the new row.
|
|
430
|
+
*/
|
|
431
|
+
export function bootstraprole(username = '') {
|
|
432
|
+
if (adminusernames.includes(String(username).toLowerCase())) {
|
|
433
|
+
return 'admin';
|
|
434
|
+
}
|
|
435
|
+
return 'user';
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* creates the CODEOWNERS admin accounts with the resolved bootstrap
|
|
439
|
+
* password (the env pin, or the per-boot mint) when the database does not
|
|
440
|
+
* carry them yet. idempotent: an admin the operator re-registered with a
|
|
441
|
+
* rotated password is never overwritten. runs on every node boot so a
|
|
442
|
+
* wiped database never locks the admin surface out. when the password was
|
|
443
|
+
* minted for this boot, the one-time secret is written to the operator
|
|
444
|
+
* console exactly once - only for the rows that actually landed.
|
|
445
|
+
*
|
|
446
|
+
* @returns the admin usernames that were created.
|
|
447
|
+
*/
|
|
448
|
+
export function seedadmins() {
|
|
449
|
+
const created = [];
|
|
450
|
+
try {
|
|
451
|
+
const seed = resolveadminseed();
|
|
452
|
+
for (const name of adminusernames) {
|
|
453
|
+
if (store.finduserbyname(name) === null) {
|
|
454
|
+
const { passwordhash, salt } = hashpassword(seed.password);
|
|
455
|
+
store.createuser({
|
|
456
|
+
username: name,
|
|
457
|
+
passwordhash,
|
|
458
|
+
salt,
|
|
459
|
+
role: 'admin',
|
|
460
|
+
});
|
|
461
|
+
store.addaudit({ userid: null, action: 'admin.seed', detail: `seeded ${name}`, ip: 'local' });
|
|
462
|
+
created.push(name);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (seed.minted && created.length > 0) {
|
|
466
|
+
process.stdout.write(`admin seed: ${created.join(', ')} - one-time bootstrap password ${seed.password} ` +
|
|
467
|
+
`(pin it with ${adminseedenv}; rotate it after the first sign-in)\n`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
/* the store is not ready (tests with throwaway handles); skip */
|
|
472
|
+
}
|
|
473
|
+
return created;
|
|
474
|
+
}
|
|
475
|
+
/** burn dummy re-exported for tests that assert timing equality. */
|
|
476
|
+
export { burndummy };
|