herdr-remote-relay 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dibin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # herdr-remote-relay
2
+
3
+ Standalone relay server for [Herdr Remote](https://www.npmjs.com/package/herdr-remote):
4
+ it serves the mobile web terminal and brokers browser ↔ workstation sessions.
5
+
6
+ Its only runtime dependency is `ws`, so the machine running it needs no
7
+ compiler, no Herdr and no plugin. It never runs a shell and never touches your
8
+ Herdr socket — only the host connector on your own workstation does that.
9
+
10
+ **You usually do not need to install this yourself.** `herdr-remote` starts a
11
+ relay locally unless you point it at one you host. Install this package only
12
+ when you want to reach your workstation from outside your own network.
13
+
14
+ ```bash
15
+ npm install -g herdr-remote-relay
16
+ herdr-remote-relay --public-url https://herdr.example.com \
17
+ --password your-password --admin-token your-long-random-admin-token \
18
+ --trust-proxy
19
+ ```
20
+
21
+ Requirements: Node.js 22+, a domain name, and TLS. Terminate TLS in a reverse
22
+ proxy in front of the relay — nginx, Caddy, Traefik and Cloudflare Tunnel all
23
+ work. Example configs for nginx, Cloudflare Tunnel, systemd and Docker Compose
24
+ ship in `deploy/` inside this package.
25
+
26
+ ## Docker
27
+
28
+ ```bash
29
+ docker run -d --name herdr-relay --restart unless-stopped \
30
+ -p 127.0.0.1:8787:8787 \
31
+ -v herdr-relay:/data \
32
+ -e RELAY_BIND=0.0.0.0 \
33
+ -e RELAY_PUBLIC_URL=https://herdr.example.com \
34
+ -e RELAY_PASSWORD=your-password \
35
+ -e RELAY_ADMIN_TOKEN=your-long-random-admin-token \
36
+ -e RELAY_TRUST_PROXY=1 \
37
+ -e RELAY_AUTH_STATE_FILE=/data/relay-auth.json \
38
+ node:22-alpine npx -y herdr-remote-relay
39
+ ```
40
+
41
+ ## Options
42
+
43
+ Every setting has a flag, an environment variable and a JSON config file key
44
+ (`--config`, or `HERDR_RELAY_CONFIG`). Precedence runs defaults → file →
45
+ environment → flags.
46
+
47
+ | Variable | Default | |
48
+ |---|---|---|
49
+ | `RELAY_PUBLIC_URL` | `http://127.0.0.1:8787` | URL browsers open |
50
+ | `RELAY_PASSWORD` | *(none)* | Password a workstation must present. **Empty = public relay** |
51
+ | `RELAY_ADMIN_TOKEN` | *(none)* | Operator token for the relay dashboard at `/admin` |
52
+ | `RELAY_BIND` | `127.0.0.1` | Listen address (`0.0.0.0` in Docker) |
53
+ | `RELAY_PORT` | `8787` | Listen port |
54
+ | `RELAY_TRUST_PROXY` | `0` | Set to `1` behind a reverse proxy |
55
+ | `RELAY_AUTH_STATE_FILE` | `~/.local/state/herdr-remote-relay/relay-auth.json` | Device records |
56
+ | `RELAY_ALLOWED_ORIGINS` | *(same-origin)* | Extra browser origins, comma separated |
57
+ | `RELAY_MAX_CLIENTS_PER_HOST` | `16` | Browsers per workstation |
58
+ | `RELAY_DEPLOYMENT_MODE` | `remote` | `local` is reserved for the workstation-managed relay |
59
+
60
+ Whatever proxy you put in front must forward WebSocket upgrades and must not
61
+ time out idle connections — a terminal is idle between keystrokes.
62
+
63
+ ## Public relays
64
+
65
+ Leaving `RELAY_PASSWORD` empty makes the relay public: anyone may connect a
66
+ workstation to it. That is safe to share, because each workstation is reachable
67
+ only through its own host token — generated on that machine, never handed out
68
+ by the relay. Nobody else can pair a device to your terminal.
69
+
70
+ What a public relay does give away is bandwidth and the fact that your
71
+ workstation is online. Set a password if that matters.
72
+
73
+ ## Connect a workstation
74
+
75
+ On the workstation, run `herdr-remote`, open the **Relay** tab and set the
76
+ access mode to **Self-hosted relay**, the relay URL to
77
+ `wss://herdr.example.com`, and the relay password to the same `RELAY_PASSWORD`.
78
+ Then check:
79
+
80
+ ```bash
81
+ curl https://herdr.example.com/healthz # hosts should be 1
82
+ ```
83
+
84
+ | Symptom | Cause |
85
+ |---|---|
86
+ | `hosts: 0` | Workstation not connected — check `herdr-remote status` |
87
+ | `relay_password_required` | Password differs between the two sides |
88
+ | Page loads, terminal never opens | Proxy is not forwarding `Upgrade` headers |
89
+ | Drops after ~60s idle | Proxy read timeout too short |
90
+
91
+ ## Security
92
+
93
+ - Tokens are stored as SHA-256 hashes, never in clear text. Terminal content is
94
+ never written to disk.
95
+ - Pairing codes are single-use, expire in ten minutes, and are rate limited.
96
+ - The `/admin` operator credential is separate from the workstation join
97
+ password and from paired device tokens.
98
+ - Losing the state file just means re-pairing your devices.
99
+
100
+ ## License
101
+
102
+ MIT
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Standalone entry point for the Herdr Remote relay.
5
+ //
6
+ // This binary has no knowledge of the herdr-remote plugin: it can be installed
7
+ // and run on its own host (`npm i -g herdr-remote-relay`) with nothing but
8
+ // environment variables for configuration.
9
+
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+ const { loadRelayConfig, PACKAGE_ROOT } = require('../src/relay-config');
13
+ const { RelayServer, VERSION, PROTOCOL_VERSION } = require('../src/relay-server');
14
+
15
+ const USAGE = `herdr-remote-relay ${VERSION} — standalone relay for Herdr Remote
16
+
17
+ Usage: herdr-remote-relay [options]
18
+
19
+ Options:
20
+ --password <password> Password a workstation must present (RELAY_PASSWORD).
21
+ Leave unset for a public relay.
22
+ --admin-token <token> Operator token for the relay dashboard (RELAY_ADMIN_TOKEN).
23
+ --deployment-mode <mode> WebUI deployment mode: local or remote (default remote).
24
+ --public-url <url> Public URL browsers use (RELAY_PUBLIC_URL)
25
+ --bind <address> Listen address (default 127.0.0.1, RELAY_BIND)
26
+ --port <number> Listen port (default 8787, RELAY_PORT)
27
+ --trust-proxy Read X-Forwarded-For for rate limiting (RELAY_TRUST_PROXY)
28
+ --state-file <file> Where device/host records are stored (RELAY_AUTH_STATE_FILE)
29
+ --allowed-origins <list> Extra comma-separated browser origins (RELAY_ALLOWED_ORIGINS)
30
+ --max-clients <number> Max browsers per workstation (RELAY_MAX_CLIENTS_PER_HOST)
31
+ --config <file> JSON config file (also HERDR_RELAY_CONFIG)
32
+ -h, --help Show this help
33
+ -v, --version Show the version
34
+
35
+ See docs/self-hosted-relay.md.
36
+ `;
37
+
38
+ function webUiBuilt() {
39
+ return fs.existsSync(path.join(PACKAGE_ROOT, 'web', 'dist', 'index.html'));
40
+ }
41
+
42
+ async function main(argv = process.argv.slice(2)) {
43
+ let loaded;
44
+ try {
45
+ loaded = loadRelayConfig({ argv });
46
+ } catch (error) {
47
+ process.stderr.write(`herdr-remote-relay: ${error.message}\n`);
48
+ process.exitCode = 1;
49
+ return;
50
+ }
51
+ const { config, help, version, errors, warnings, configFile } = loaded;
52
+
53
+ if (help) {
54
+ process.stdout.write(USAGE);
55
+ return;
56
+ }
57
+ if (version) {
58
+ process.stdout.write(`${VERSION}\n`);
59
+ return;
60
+ }
61
+ if (errors.length > 0) {
62
+ for (const message of errors) process.stderr.write(`herdr-remote-relay: ${message}\n`);
63
+ process.stderr.write(USAGE);
64
+ process.exitCode = 2;
65
+ return;
66
+ }
67
+
68
+ const server = new RelayServer(config);
69
+ let address;
70
+ try {
71
+ address = await server.listen(config.relay.port, config.relay.host);
72
+ } catch (error) {
73
+ const hint = error.code === 'EADDRINUSE'
74
+ ? ` (port ${config.relay.port} is already in use)`
75
+ : error.code === 'EACCES'
76
+ ? ` (no permission to bind port ${config.relay.port})`
77
+ : '';
78
+ process.stderr.write(`herdr-remote-relay: failed to listen${hint}: ${error.message}\n`);
79
+ process.exitCode = 1;
80
+ return;
81
+ }
82
+
83
+ process.stdout.write(`herdr-remote-relay ${VERSION} (protocol ${PROTOCOL_VERSION})\n`);
84
+ process.stdout.write(` listening http://${config.relay.host}:${address.port}\n`);
85
+ process.stdout.write(` public url ${config.relay.publicUrl}\n`);
86
+ process.stdout.write(` password ${config.auth.password ? 'set' : 'not set (public relay)'}\n`);
87
+ process.stdout.write(` admin ${config.auth.adminToken ? 'set (/admin)' : 'not configured'}\n`);
88
+ process.stdout.write(` state file ${server.stateFile}\n`);
89
+ if (configFile) process.stdout.write(` config file ${configFile}\n`);
90
+ if (!webUiBuilt()) {
91
+ process.stdout.write(' web ui NOT BUILT (run "npm run build" in this package)\n');
92
+ }
93
+ for (const warning of warnings) process.stdout.write(` warning: ${warning}\n`);
94
+
95
+ let closing = false;
96
+ const stop = (signal) => {
97
+ if (closing) return;
98
+ closing = true;
99
+ process.stdout.write(`herdr-remote-relay: ${signal} received, shutting down\n`);
100
+ server.close().finally(() => process.exit(0));
101
+ };
102
+ process.on('SIGINT', () => stop('SIGINT'));
103
+ process.on('SIGTERM', () => stop('SIGTERM'));
104
+ }
105
+
106
+ if (require.main === module) {
107
+ main().catch((error) => {
108
+ process.stderr.write(`herdr-remote-relay: ${error.stack || error.message}\n`);
109
+ process.exitCode = 1;
110
+ });
111
+ }
112
+
113
+ module.exports = { main, USAGE };
@@ -0,0 +1,14 @@
1
+ {
2
+ "relay": {
3
+ "mode": "remote",
4
+ "host": "127.0.0.1",
5
+ "port": 8787,
6
+ "publicUrl": "https://herdr.example.com",
7
+ "trustProxy": true
8
+ },
9
+ "auth": {
10
+ "password": "change-me",
11
+ "adminToken": "replace-with-a-long-random-operator-token",
12
+ "stateFile": "/var/lib/herdr-remote-relay/relay-auth.json"
13
+ }
14
+ }
@@ -0,0 +1,9 @@
1
+ # Copy this file to ~/.cloudflared/config.yml after creating a named tunnel.
2
+ # The relay itself remains bound to 127.0.0.1.
3
+ tunnel: YOUR_TUNNEL_UUID
4
+ credentials-file: /home/YOUR_USER/.cloudflared/YOUR_TUNNEL_UUID.json
5
+
6
+ ingress:
7
+ - hostname: herdr.example.com
8
+ service: http://127.0.0.1:8787
9
+ - service: http_status:404
@@ -0,0 +1,23 @@
1
+ # Put your own reverse proxy (nginx, Caddy, Traefik, Cloudflare Tunnel…) in
2
+ # front of port 8787 and terminate TLS there.
3
+
4
+ services:
5
+ relay:
6
+ image: node:22-alpine
7
+ command: npx -y herdr-remote-relay
8
+ restart: unless-stopped
9
+ ports:
10
+ - "127.0.0.1:8787:8787"
11
+ environment:
12
+ RELAY_BIND: 0.0.0.0
13
+ RELAY_PORT: "8787"
14
+ RELAY_PUBLIC_URL: https://herdr.example.com
15
+ # Leave empty for a public relay.
16
+ RELAY_PASSWORD: change-me
17
+ RELAY_TRUST_PROXY: "1"
18
+ RELAY_AUTH_STATE_FILE: /data/relay-auth.json
19
+ volumes:
20
+ - relay-state:/data
21
+
22
+ volumes:
23
+ relay-state:
@@ -0,0 +1,49 @@
1
+ # nginx site for a self-hosted relay. Obtain the certificate first, e.g. with
2
+ # sudo certbot --nginx -d herdr.example.com
3
+
4
+ map $http_upgrade $connection_upgrade {
5
+ default upgrade;
6
+ '' close;
7
+ }
8
+
9
+ server {
10
+ listen 80;
11
+ listen [::]:80;
12
+ server_name herdr.example.com;
13
+ return 301 https://$host$request_uri;
14
+ }
15
+
16
+ server {
17
+ listen 443 ssl;
18
+ listen [::]:443 ssl;
19
+ http2 on;
20
+ server_name herdr.example.com;
21
+
22
+ ssl_certificate /etc/letsencrypt/live/herdr.example.com/fullchain.pem;
23
+ ssl_certificate_key /etc/letsencrypt/live/herdr.example.com/privkey.pem;
24
+
25
+ location / {
26
+ proxy_pass http://127.0.0.1:8787;
27
+ proxy_http_version 1.1;
28
+
29
+ # Required for /ws/host and /ws/client. Without these the browser falls
30
+ # back to nothing: the terminal never opens.
31
+ proxy_set_header Upgrade $http_upgrade;
32
+ proxy_set_header Connection $connection_upgrade;
33
+
34
+ proxy_set_header Host $host;
35
+ # The relay checks the Origin header against the request Host, and rate
36
+ # limits pairing per client address.
37
+ proxy_set_header X-Real-IP $remote_addr;
38
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
39
+ proxy_set_header X-Forwarded-Proto $scheme;
40
+
41
+ # A terminal can sit idle for a long time between keystrokes; the
42
+ # default 60s would drop the session.
43
+ proxy_read_timeout 3600s;
44
+ proxy_send_timeout 3600s;
45
+
46
+ # Terminal output must arrive as it is produced.
47
+ proxy_buffering off;
48
+ }
49
+ }
@@ -0,0 +1,41 @@
1
+ # System-wide unit for a self-hosted relay.
2
+ #
3
+ # sudo cp herdr-remote-relay.service /etc/systemd/system/
4
+ # sudo useradd --system --home /var/lib/herdr-remote-relay --create-home herdr-relay
5
+ # sudo install -m 600 -o herdr-relay -g herdr-relay relay.env /etc/herdr-remote-relay.env
6
+ # sudo systemctl enable --now herdr-remote-relay
7
+
8
+ [Unit]
9
+ Description=Herdr Remote relay
10
+ Documentation=https://github.com/herdr/herdr-remote
11
+ After=network-online.target
12
+ Wants=network-online.target
13
+
14
+ [Service]
15
+ Type=simple
16
+ User=herdr-relay
17
+ Group=herdr-relay
18
+ # Tokens live here, not on the command line, where `ps` would expose them.
19
+ EnvironmentFile=/etc/herdr-remote-relay.env
20
+ ExecStart=/usr/bin/npx herdr-remote-relay
21
+ Restart=always
22
+ RestartSec=3
23
+ StateDirectory=herdr-remote-relay
24
+ WorkingDirectory=/var/lib/herdr-remote-relay
25
+
26
+ # The relay reads no user files and runs no subprocesses; it only needs its own
27
+ # state directory and a socket.
28
+ NoNewPrivileges=true
29
+ PrivateTmp=true
30
+ ProtectSystem=strict
31
+ ProtectHome=true
32
+ ProtectKernelTunables=true
33
+ ProtectKernelModules=true
34
+ ProtectControlGroups=true
35
+ RestrictAddressFamilies=AF_INET AF_INET6
36
+ RestrictNamespaces=true
37
+ LockPersonality=true
38
+ MemoryDenyWriteExecute=false
39
+
40
+ [Install]
41
+ WantedBy=multi-user.target
@@ -0,0 +1,20 @@
1
+ # Copy to /etc/herdr-remote-relay.env with mode 0600.
2
+
3
+ RELAY_PUBLIC_URL=https://herdr.example.com
4
+ # Standalone/self-hosted relay; the workstation-managed relay sets this to local.
5
+ RELAY_DEPLOYMENT_MODE=remote
6
+ RELAY_BIND=127.0.0.1
7
+ RELAY_PORT=8787
8
+
9
+ # Password a workstation must present. Leave empty for a public relay.
10
+ RELAY_PASSWORD=change-me
11
+
12
+ # Credential for the relay operator dashboard at /admin. Keep this separate
13
+ # from RELAY_PASSWORD; it is sent only in X-Relay-Admin-Token.
14
+ RELAY_ADMIN_TOKEN=replace-with-a-long-random-operator-token
15
+
16
+ # Set when a reverse proxy sits in front, so pairing rate limits key on the
17
+ # real client address instead of the proxy's.
18
+ RELAY_TRUST_PROXY=1
19
+
20
+ RELAY_AUTH_STATE_FILE=/var/lib/herdr-remote-relay/relay-auth.json
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "herdr-remote-relay",
3
+ "version": "0.2.0",
4
+ "description": "Standalone relay server for Herdr Remote: serves the mobile web terminal and brokers browser <-> workstation sessions",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/dibin666/herdr-remote.git",
9
+ "directory": "packages/relay"
10
+ },
11
+ "homepage": "https://github.com/dibin666/herdr-remote#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/dibin666/herdr-remote/issues"
14
+ },
15
+ "keywords": [
16
+ "herdr",
17
+ "relay",
18
+ "terminal",
19
+ "websocket",
20
+ "remote"
21
+ ],
22
+ "engines": {
23
+ "node": ">=22.0.0"
24
+ },
25
+ "bin": {
26
+ "herdr-remote-relay": "./bin/herdr-remote-relay.js"
27
+ },
28
+ "main": "./src/relay-server.js",
29
+ "exports": {
30
+ ".": "./src/relay-server.js",
31
+ "./protocol": "./src/stream-frame.js",
32
+ "./config": "./src/relay-config.js",
33
+ "./package.json": "./package.json"
34
+ },
35
+ "files": [
36
+ "bin",
37
+ "src",
38
+ "web/dist",
39
+ "deploy",
40
+ "config.example.json"
41
+ ],
42
+ "scripts": {
43
+ "build": "npm --prefix web run build",
44
+ "start": "node bin/herdr-remote-relay.js",
45
+ "test": "node --test tests/*.test.js"
46
+ },
47
+ "dependencies": {
48
+ "ws": "^8.18.0"
49
+ }
50
+ }
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const { randomToken, readJson, writeJsonAtomic, ensureDir } = require('./state');
5
+
6
+ function hash(value) {
7
+ return crypto.createHash('sha256').update(String(value), 'utf8').digest('hex');
8
+ }
9
+
10
+ function equalHash(left, right) {
11
+ if (typeof left !== 'string' || typeof right !== 'string') return false;
12
+ const a = Buffer.from(left, 'hex');
13
+ const b = Buffer.from(right, 'hex');
14
+ return a.length === b.length && a.length > 0 && crypto.timingSafeEqual(a, b);
15
+ }
16
+
17
+ function nowIso(now = Date.now()) {
18
+ return new Date(now).toISOString();
19
+ }
20
+
21
+ class AuthStore {
22
+ constructor({ stateFile, pairingTtlMs = 10 * 60 * 1000, deviceTtlMs = 30 * 24 * 60 * 60 * 1000, maxDevices = 32, password = null } = {}) {
23
+ if (!stateFile) throw new TypeError('stateFile is required');
24
+ this.stateFile = stateFile;
25
+ this.pairingTtlMs = pairingTtlMs;
26
+ this.deviceTtlMs = deviceTtlMs;
27
+ this.maxDevices = maxDevices;
28
+ // Optional shared password. When unset the relay is public: anyone may
29
+ // enrol a workstation. That is safe because a workstation is only ever
30
+ // reachable through its own host token, which the relay never hands out.
31
+ this.password = password || null;
32
+ this.pairings = new Map();
33
+ this.lastDeviceSaveAt = new Map();
34
+ this.state = readJson(stateFile, { version: 1, hosts: {}, devices: {} });
35
+ this.state.version = 1;
36
+ this.state.hosts = this.state.hosts && typeof this.state.hosts === 'object' ? this.state.hosts : {};
37
+ this.state.devices = this.state.devices && typeof this.state.devices === 'object' ? this.state.devices : {};
38
+ ensureDir(require('node:path').dirname(stateFile));
39
+ }
40
+
41
+ save() {
42
+ writeJsonAtomic(this.stateFile, this.state);
43
+ }
44
+
45
+ /** Does this request carry the relay password, if one is required at all? */
46
+ checkPassword(supplied) {
47
+ if (!this.password) return true;
48
+ return typeof supplied === 'string' && equalHash(hash(supplied), hash(this.password));
49
+ }
50
+
51
+ /**
52
+ * Enrol a workstation, or re-authenticate one that is already enrolled.
53
+ *
54
+ * The password (when the relay has one) decides who may *join*. The host
55
+ * token decides who may act *as a given workstation*: it is generated on the
56
+ * workstation and only its hash is ever stored here, so even on a public
57
+ * relay nobody else can impersonate an enrolled host or pair a device to it.
58
+ */
59
+ registerHost(hostId, token, password = null, now = Date.now()) {
60
+ if (typeof hostId !== 'string' || hostId.length < 1 || hostId.length > 128 || typeof token !== 'string' || token.length < 16) {
61
+ return { ok: false, code: 'invalid_host_credentials', message: 'hostId and token are required' };
62
+ }
63
+ if (!this.checkPassword(password)) {
64
+ return { ok: false, code: 'relay_password_required', message: 'the relay password is missing or wrong' };
65
+ }
66
+ const existing = this.state.hosts[hostId];
67
+ if (existing) {
68
+ if (!equalHash(existing.tokenHash, hash(token))) {
69
+ return { ok: false, code: 'host_auth_failed', message: 'host token is invalid' };
70
+ }
71
+ existing.lastSeenAt = nowIso(now);
72
+ this.save();
73
+ return { ok: true, hostId, firstSeen: false };
74
+ }
75
+
76
+ this.state.hosts[hostId] = {
77
+ tokenHash: hash(token),
78
+ createdAt: nowIso(now),
79
+ lastSeenAt: nowIso(now),
80
+ };
81
+ this.save();
82
+ return { ok: true, hostId, firstSeen: true };
83
+ }
84
+
85
+ /** Verify a host token without enrolling anything. */
86
+ authenticateHost(hostId, token) {
87
+ if (typeof hostId !== 'string' || typeof token !== 'string' || token.length < 16) return false;
88
+ const existing = this.state.hosts[hostId];
89
+ return Boolean(existing) && equalHash(existing.tokenHash, hash(token));
90
+ }
91
+
92
+ hostCount() {
93
+ return Object.keys(this.state.hosts).length;
94
+ }
95
+
96
+ startPairing(hostId, publicUrl, now = Date.now()) {
97
+ if (!this.state.hosts[hostId]) {
98
+ const error = new Error('host is not connected or enrolled');
99
+ error.code = 'host_not_found';
100
+ throw error;
101
+ }
102
+ this.cleanup(now);
103
+ let code;
104
+ do {
105
+ code = randomToken(4).toUpperCase().replace(/[-_]/g, '').slice(0, 6);
106
+ } while ([...this.pairings.values()].some((pairing) => pairing.codeHash === hash(code)));
107
+ const expiresAt = now + this.pairingTtlMs;
108
+ this.pairings.set(code, {
109
+ codeHash: hash(code),
110
+ hostId,
111
+ expiresAt,
112
+ publicUrl,
113
+ });
114
+ return { code, hostId, publicUrl, expiresAt, expiresAtIso: nowIso(expiresAt) };
115
+ }
116
+
117
+ completePairing(code, now = Date.now()) {
118
+ if (typeof code !== 'string' || code.length < 4 || code.length > 32) return null;
119
+ this.cleanup(now);
120
+ const normalized = code.trim().toUpperCase();
121
+ const pairing = this.pairings.get(normalized);
122
+ if (!pairing || pairing.expiresAt <= now || pairing.codeHash !== hash(normalized)) return null;
123
+ this.pairings.delete(normalized);
124
+
125
+ const devices = Object.values(this.state.devices);
126
+ if (devices.length >= this.maxDevices) {
127
+ devices.sort((a, b) => (a.lastSeenAt || '').localeCompare(b.lastSeenAt || ''));
128
+ delete this.state.devices[devices[0].deviceId];
129
+ }
130
+ const deviceId = `device-${randomToken(9)}`;
131
+ const token = randomToken(32);
132
+ const expiresAt = now + this.deviceTtlMs;
133
+ this.state.devices[deviceId] = {
134
+ deviceId,
135
+ hostId: pairing.hostId,
136
+ tokenHash: hash(token),
137
+ createdAt: nowIso(now),
138
+ lastSeenAt: nowIso(now),
139
+ expiresAt,
140
+ };
141
+ this.save();
142
+ return { deviceId, hostId: pairing.hostId, token, expiresAt, expiresAtIso: nowIso(expiresAt), publicUrl: pairing.publicUrl };
143
+ }
144
+
145
+ authenticateDevice(token, now = Date.now()) {
146
+ if (typeof token !== 'string' || token.length < 16) return null;
147
+ const tokenHash = hash(token);
148
+ for (const device of Object.values(this.state.devices)) {
149
+ if (device.expiresAt <= now) continue;
150
+ if (!equalHash(device.tokenHash, tokenHash)) continue;
151
+ device.lastSeenAt = nowIso(now);
152
+ device.expiresAt = now + this.deviceTtlMs;
153
+ const lastSaveAt = this.lastDeviceSaveAt.get(device.deviceId) || 0;
154
+ if (now - lastSaveAt >= 60 * 1000) {
155
+ this.lastDeviceSaveAt.set(device.deviceId, now);
156
+ this.save();
157
+ }
158
+ return { ...device };
159
+ }
160
+ return null;
161
+ }
162
+
163
+ cleanup(now = Date.now()) {
164
+ let removedDevices = 0;
165
+ for (const [deviceId, device] of Object.entries(this.state.devices)) {
166
+ if (!device || device.expiresAt <= now) {
167
+ delete this.state.devices[deviceId];
168
+ this.lastDeviceSaveAt.delete(deviceId);
169
+ removedDevices += 1;
170
+ }
171
+ }
172
+ let removedPairings = 0;
173
+ for (const [code, pairing] of this.pairings.entries()) {
174
+ if (pairing.expiresAt <= now) {
175
+ this.pairings.delete(code);
176
+ removedPairings += 1;
177
+ }
178
+ }
179
+ if (removedDevices) this.save();
180
+ return { removedDevices, removedPairings };
181
+ }
182
+
183
+ deviceCount() {
184
+ return Object.keys(this.state.devices).length;
185
+ }
186
+ }
187
+
188
+ module.exports = { AuthStore, hash, equalHash, nowIso };