@huynle/hub 0.0.0-stage → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +269 -0
- package/Dockerfile +51 -0
- package/LICENSE +21 -0
- package/README.md +1029 -2
- package/bin/hub.ts +20 -0
- package/docker/hub-deps.ts +42 -0
- package/package.json +85 -4
- package/rpc.ts +2 -0
- package/scripts/hub-local.sh +28 -0
- package/scripts/io.github.huynle.hub.plist +42 -0
- package/server.ts +3 -0
- package/src/central/assets.ts +74 -0
- package/src/central/auth.ts +271 -0
- package/src/central/config.ts +310 -0
- package/src/central/http.ts +1247 -0
- package/src/central/main.ts +136 -0
- package/src/central/oidc.ts +295 -0
- package/src/central/push-target.ts +97 -0
- package/src/central/push.ts +285 -0
- package/src/central/registry.ts +1153 -0
- package/src/central/speech/models.ts +89 -0
- package/src/central/speech/openai.ts +66 -0
- package/src/central/speech/openrouter.ts +71 -0
- package/src/central/speech/provider.ts +74 -0
- package/src/central/speech/service.ts +168 -0
- package/src/central/speech/settings.ts +375 -0
- package/src/central/spoke-ws.ts +300 -0
- package/src/central/store.ts +653 -0
- package/src/config.ts +170 -0
- package/src/env.ts +63 -0
- package/src/index.ts +236 -0
- package/src/key.ts +17 -0
- package/src/loopback.ts +41 -0
- package/src/migrate.ts +105 -0
- package/src/openrouter.ts +3 -0
- package/src/options.ts +33 -0
- package/src/protocol.ts +650 -0
- package/src/reply.ts +27 -0
- package/src/rpc.ts +98 -0
- package/src/setup.ts +124 -0
- package/src/speakable.ts +35 -0
- package/src/spoke/client.ts +272 -0
- package/src/spoke/conversation.ts +93 -0
- package/src/spoke/enroll.ts +79 -0
- package/src/spoke/herdr.ts +346 -0
- package/src/spoke/listing.ts +108 -0
- package/src/spoke/localhub.ts +287 -0
- package/src/spoke/locations.ts +34 -0
- package/src/spoke/own-server.ts +32 -0
- package/src/spoke/presence.ts +45 -0
- package/src/spoke/singleton.ts +103 -0
- package/src/spoke/spoke.ts +1212 -0
- package/src/spoke/summary.ts +117 -0
- package/src/spoke/timeline.ts +93 -0
- package/src/spoke/tracker.ts +345 -0
- package/src/spoke/watch.ts +78 -0
- package/src/status.ts +232 -0
- package/src/tui-commands.ts +53 -0
- package/src/tui-forms.ts +77 -0
- package/src/tui-presence.ts +75 -0
- package/src/tui.ts +479 -0
- package/src/turns.ts +94 -0
- package/src/voice-command.ts +65 -0
- package/tui.ts +2 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-DClBvfLL.css +1 -0
- package/web/dist/assets/index-Djo_yF0a.js +12 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +18 -0
- package/web/dist/index.html +25 -0
- package/web/dist/manifest.webmanifest +18 -0
- package/web/dist/sw.js +151 -0
- package/web/dist/vad-worklet.js +27 -0
package/bin/hub.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Hub server entry point (`hub-server`, aliases `opencode-hub` and `opencode-voice-hub`): `bun bin/hub.ts [--help | --version]`,
|
|
3
|
+
// configured by HUB_* env vars over ~/.config/hub/hub.json ($HUB_CONFIG); see src/central/config.ts. Serves the
|
|
4
|
+
// built web app (web/dist, `npm run build:web`) from disk. The standalone binaries embed it instead
|
|
5
|
+
// (scripts/build-binaries.sh).
|
|
6
|
+
import { readFileSync } from "node:fs"
|
|
7
|
+
import path from "node:path"
|
|
8
|
+
import { diskAssets } from "../src/central/assets"
|
|
9
|
+
import { runHub } from "../src/central/main"
|
|
10
|
+
|
|
11
|
+
const root = path.join(import.meta.dir, "..")
|
|
12
|
+
const version = (() => {
|
|
13
|
+
try {
|
|
14
|
+
return String(JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version ?? "0.0.0")
|
|
15
|
+
} catch {
|
|
16
|
+
return "0.0.0"
|
|
17
|
+
}
|
|
18
|
+
})()
|
|
19
|
+
|
|
20
|
+
runHub({ web: diskAssets(path.join(root, "web", "dist")), version })
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Docker build helper: copies the hub's runtime packages (and everything they depend on, as resolved by the
|
|
2
|
+
// lockfile install in ./node_modules) into <out>/node_modules. The plugin-only @opencode/* packages are left out.
|
|
3
|
+
// bun docker/hub-deps.ts /deps web-push
|
|
4
|
+
import { cpSync, existsSync, readFileSync } from "node:fs"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
|
|
7
|
+
const [out, ...roots] = process.argv.slice(2)
|
|
8
|
+
if (!out || roots.length === 0) throw new Error("usage: hub-deps.ts <out-dir> <package>...")
|
|
9
|
+
const top = path.resolve("node_modules")
|
|
10
|
+
|
|
11
|
+
/** Node resolution: nearest node_modules/<name> walking up from `from`, stopping at the project root. */
|
|
12
|
+
function resolve(from: string, name: string): string {
|
|
13
|
+
for (let dir = from; dir.startsWith(top); dir = path.dirname(dir)) {
|
|
14
|
+
const candidate = path.join(dir, "node_modules", name)
|
|
15
|
+
if (existsSync(path.join(candidate, "package.json"))) return candidate
|
|
16
|
+
}
|
|
17
|
+
const candidate = path.join(top, name)
|
|
18
|
+
if (existsSync(path.join(candidate, "package.json"))) return candidate
|
|
19
|
+
throw new Error(`cannot resolve ${name} from ${from}`)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const seen = new Set<string>()
|
|
23
|
+
const queue = roots.map((name) => resolve(top, name))
|
|
24
|
+
while (queue.length) {
|
|
25
|
+
const dir = queue.pop()!
|
|
26
|
+
if (seen.has(dir)) continue
|
|
27
|
+
seen.add(dir)
|
|
28
|
+
const pkg = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8"))
|
|
29
|
+
for (const name of Object.keys({ ...pkg.dependencies, ...pkg.optionalDependencies })) {
|
|
30
|
+
try {
|
|
31
|
+
queue.push(resolve(dir, name))
|
|
32
|
+
} catch (cause) {
|
|
33
|
+
if (!pkg.optionalDependencies?.[name]) throw cause
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Copy outermost package directories only; nested node_modules come along with their parent.
|
|
39
|
+
const dirs = [...seen].sort()
|
|
40
|
+
const outer = dirs.filter((dir) => !dirs.some((other) => other !== dir && dir.startsWith(other + path.sep)))
|
|
41
|
+
for (const dir of outer) cpSync(dir, path.join(out, "node_modules", path.relative(top, dir)), { recursive: true })
|
|
42
|
+
console.log(`hub deps: ${outer.length} packages → ${out}/node_modules`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,87 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Hub: a harness-agnostic central hub your AI agent harnesses connect to (OpenCode first). Follow, steer and stop sessions from your phone or browser, with notifications, optional hands-free Voice and a self-hostable server.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Huy Le",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/huynle/hub.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/huynle/hub#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/huynle/hub/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"opencode",
|
|
17
|
+
"opencode-plugin",
|
|
18
|
+
"hub",
|
|
19
|
+
"remote",
|
|
20
|
+
"console",
|
|
21
|
+
"voice",
|
|
22
|
+
"speech",
|
|
23
|
+
"tts",
|
|
24
|
+
"stt",
|
|
25
|
+
"pwa",
|
|
26
|
+
"agent",
|
|
27
|
+
"harness"
|
|
28
|
+
],
|
|
29
|
+
"type": "module",
|
|
30
|
+
"main": "./server.ts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./server.ts",
|
|
33
|
+
"./server": "./server.ts",
|
|
34
|
+
"./tui": "./tui.ts",
|
|
35
|
+
"./rpc": "./rpc.ts",
|
|
36
|
+
"./package.json": "./package.json"
|
|
37
|
+
},
|
|
38
|
+
"bin": {
|
|
39
|
+
"hub-server": "bin/hub.ts",
|
|
40
|
+
"opencode-hub": "bin/hub.ts",
|
|
41
|
+
"opencode-voice-hub": "bin/hub.ts"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"server.ts",
|
|
45
|
+
"tui.ts",
|
|
46
|
+
"rpc.ts",
|
|
47
|
+
"src",
|
|
48
|
+
"bin",
|
|
49
|
+
"web/dist",
|
|
50
|
+
"docker",
|
|
51
|
+
"Dockerfile",
|
|
52
|
+
"scripts/hub-local.sh",
|
|
53
|
+
"scripts/io.github.huynle.hub.plist",
|
|
54
|
+
"README.md",
|
|
55
|
+
"CHANGELOG.md",
|
|
56
|
+
"LICENSE"
|
|
57
|
+
],
|
|
58
|
+
"engines": {
|
|
59
|
+
"bun": ">=1.3"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
},
|
|
64
|
+
"scripts": {
|
|
65
|
+
"test": "bun test",
|
|
66
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p web",
|
|
67
|
+
"build:web": "vite build --config web/vite.config.ts",
|
|
68
|
+
"dev:web": "vite --config web/vite.config.ts",
|
|
69
|
+
"test:e2e": "bun web/e2e/smoke.ts",
|
|
70
|
+
"prepack": "npm run build:web",
|
|
71
|
+
"hub": "bun bin/hub.ts"
|
|
72
|
+
},
|
|
73
|
+
"dependencies": {
|
|
74
|
+
"@opencode/client": "^2.0.26",
|
|
75
|
+
"@opencode/plugin": "^2.0.26",
|
|
76
|
+
"web-push": "^3.6.7"
|
|
77
|
+
},
|
|
78
|
+
"devDependencies": {
|
|
79
|
+
"@types/bun": "^1.4.2",
|
|
80
|
+
"@types/web-push": "^3.6.4",
|
|
81
|
+
"playwright-core": "^1.64.1",
|
|
82
|
+
"solid-js": "^1.9.17",
|
|
83
|
+
"typescript": "^5.9.3",
|
|
84
|
+
"vite": "^8.3.4",
|
|
85
|
+
"vite-plugin-solid": "^2.11.14"
|
|
86
|
+
}
|
|
87
|
+
}
|
package/rpc.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Run Hub on this machine in the foreground (Ctrl-C stops it).
|
|
3
|
+
# Configured by ~/.config/hub/hub.json (or $HUB_CONFIG); HUB_* env vars win over it. The legacy names
|
|
4
|
+
# ($VOICE_HUB_CONFIG, VOICE_*) still work, and on first start the hub copies a legacy hub.json and state
|
|
5
|
+
# directory from ~/.config/opencode-hub and ~/.local/state/opencode-hub (else the opencode-voice ones).
|
|
6
|
+
# Default address http://127.0.0.1:4097, state in ~/.local/state/hub. See README → Local only.
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
9
|
+
BASE="${XDG_CONFIG_HOME:-$HOME/.config}"
|
|
10
|
+
CONFIG="${HUB_CONFIG:-${VOICE_HUB_CONFIG:-$BASE/hub/hub.json}}"
|
|
11
|
+
LEGACY=""
|
|
12
|
+
for dir in opencode-hub opencode-voice; do
|
|
13
|
+
if [[ -f "$BASE/$dir/hub.json" ]]; then LEGACY="$BASE/$dir/hub.json"; break; fi
|
|
14
|
+
done
|
|
15
|
+
if [[ -f "$CONFIG" ]]; then
|
|
16
|
+
perms=$(stat -f %Lp "$CONFIG" 2>/dev/null || stat -c %a "$CONFIG")
|
|
17
|
+
if [[ "$perms" != "600" && "$perms" != "400" ]]; then
|
|
18
|
+
echo "warning: $CONFIG has mode $perms; API keys in it are ignored until you run: chmod 600 $CONFIG" >&2
|
|
19
|
+
fi
|
|
20
|
+
elif [[ -z "${HUB_CONFIG:-}${VOICE_HUB_CONFIG:-}" && -n "$LEGACY" ]]; then
|
|
21
|
+
echo "note: $CONFIG not found; the hub copies $LEGACY there on start" >&2
|
|
22
|
+
else
|
|
23
|
+
echo "note: $CONFIG not found; using env vars and defaults" >&2
|
|
24
|
+
fi
|
|
25
|
+
BUN="${BUN:-$(command -v bun || true)}"
|
|
26
|
+
[[ -n "$BUN" ]] || BUN="$HOME/.bun/bin/bun"
|
|
27
|
+
cd "$ROOT"
|
|
28
|
+
exec "$BUN" bin/hub.ts "$@"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<!--
|
|
4
|
+
Template: keep a local Hub running on macOS (launchd). Not installed by anything.
|
|
5
|
+
Install (replace __REPO__ and __HOME__ first):
|
|
6
|
+
sed -e "s|__REPO__|$PWD|g" -e "s|__HOME__|$HOME|g" scripts/io.github.huynle.hub.plist \
|
|
7
|
+
> ~/Library/LaunchAgents/io.github.huynle.hub.plist
|
|
8
|
+
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/io.github.huynle.hub.plist
|
|
9
|
+
Remove:
|
|
10
|
+
launchctl bootout gui/$(id -u)/io.github.huynle.hub && rm ~/Library/LaunchAgents/io.github.huynle.hub.plist
|
|
11
|
+
The hub reads ~/.config/hub/hub.json (mode 0600). Only one hub per machine at a time:
|
|
12
|
+
with this agent loaded, the plugin's auto-start finds it running and does nothing.
|
|
13
|
+
|
|
14
|
+
Upgrading: this replaces com.opencode-hub.hub (opencode-hub) and com.opencode-voice.hub (opencode-voice).
|
|
15
|
+
Remove the old agent first, e.g.:
|
|
16
|
+
launchctl bootout gui/$(id -u)/com.opencode-hub.hub && rm ~/Library/LaunchAgents/com.opencode-hub.hub.plist
|
|
17
|
+
-->
|
|
18
|
+
<plist version="1.0">
|
|
19
|
+
<dict>
|
|
20
|
+
<key>Label</key>
|
|
21
|
+
<string>io.github.huynle.hub</string>
|
|
22
|
+
<key>ProgramArguments</key>
|
|
23
|
+
<array>
|
|
24
|
+
<string>__REPO__/scripts/hub-local.sh</string>
|
|
25
|
+
</array>
|
|
26
|
+
<key>EnvironmentVariables</key>
|
|
27
|
+
<dict>
|
|
28
|
+
<key>PATH</key>
|
|
29
|
+
<string>__HOME__/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
30
|
+
</dict>
|
|
31
|
+
<key>RunAtLoad</key>
|
|
32
|
+
<true/>
|
|
33
|
+
<key>KeepAlive</key>
|
|
34
|
+
<true/>
|
|
35
|
+
<key>ThrottleInterval</key>
|
|
36
|
+
<integer>30</integer>
|
|
37
|
+
<key>StandardOutPath</key>
|
|
38
|
+
<string>__HOME__/.local/state/hub/hub.log</string>
|
|
39
|
+
<key>StandardErrorPath</key>
|
|
40
|
+
<string>__HOME__/.local/state/hub/hub.log</string>
|
|
41
|
+
</dict>
|
|
42
|
+
</plist>
|
package/server.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// The hub's web app files (web/dist) behind one interface: read from disk (a checkout, the npm package, the Docker
|
|
2
|
+
// image) or from files embedded in a compiled binary (`bun build --compile`, see scripts/gen-web-entry.ts).
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
|
|
6
|
+
export interface WebAssets {
|
|
7
|
+
/** The bytes of web/<name> ("app.js", "sub/x.js"), or undefined when there is no such file. */
|
|
8
|
+
read(name: string): Uint8Array<ArrayBuffer> | undefined
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Files under `dir`; anything resolving outside it (or a folder) is not found. */
|
|
12
|
+
export function diskAssets(dir: string): WebAssets {
|
|
13
|
+
const root = path.resolve(dir)
|
|
14
|
+
return {
|
|
15
|
+
read(name) {
|
|
16
|
+
if (!name) return undefined
|
|
17
|
+
const file = path.resolve(root, name)
|
|
18
|
+
if (!file.startsWith(root + path.sep) || !existsSync(file) || !statSync(file).isFile()) return undefined
|
|
19
|
+
return readFileSync(file)
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A name → path map, as `import f from "../web/dist/index.html" with { type: "file" }` gives it: a path inside the
|
|
26
|
+
* compiled binary ($bunfs), or the file on disk when the entry runs from source.
|
|
27
|
+
*/
|
|
28
|
+
export function embeddedAssets(files: Readonly<Record<string, string>>): WebAssets {
|
|
29
|
+
const table = new Map(Object.entries(files))
|
|
30
|
+
return {
|
|
31
|
+
read(name) {
|
|
32
|
+
const file = table.get(name)
|
|
33
|
+
if (!file) return undefined
|
|
34
|
+
try {
|
|
35
|
+
return readFileSync(file)
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Files that belong in the page (not type declarations, not hidden files). */
|
|
44
|
+
export function isWebAsset(name: string): boolean {
|
|
45
|
+
const base = name.split("/").pop() ?? name
|
|
46
|
+
return !base.startsWith(".") && !base.endsWith(".d.ts") && !base.endsWith(".map")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Source of a module that embeds web/ files: `export default { "<name>": <embedded path>, … }`. */
|
|
50
|
+
export function webAssetModule(names: readonly string[], from: string): string {
|
|
51
|
+
const files = names.filter(isWebAsset)
|
|
52
|
+
const imports = files.map((name, i) => `import f${i} from ${JSON.stringify(`${from}/${name}`)} with { type: "file" }`)
|
|
53
|
+
const entries = files.map((name, i) => ` ${JSON.stringify(name)}: f${i},`)
|
|
54
|
+
return [
|
|
55
|
+
"// Generated by scripts/gen-web-entry.ts: web/ files embedded in the compiled hub. Do not edit.",
|
|
56
|
+
...imports,
|
|
57
|
+
"",
|
|
58
|
+
"export default {",
|
|
59
|
+
...entries,
|
|
60
|
+
"} as Record<string, string>",
|
|
61
|
+
"",
|
|
62
|
+
].join("\n")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Files under `dir` as "/"-separated paths relative to it ("index.html", "assets/index-x.js"), sorted. */
|
|
66
|
+
export function webFiles(dir: string, prefix = ""): string[] {
|
|
67
|
+
const out: string[] = []
|
|
68
|
+
for (const entry of readdirSync(path.join(dir, prefix), { withFileTypes: true })) {
|
|
69
|
+
const name = prefix ? `${prefix}/${entry.name}` : entry.name
|
|
70
|
+
if (entry.isDirectory()) out.push(...webFiles(dir, name))
|
|
71
|
+
else if (entry.isFile()) out.push(name)
|
|
72
|
+
}
|
|
73
|
+
return out.sort()
|
|
74
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// Credentials issued by the central server:
|
|
2
|
+
// - machine enrollment (device-code flow): a spoke asks, the owner approves in the web UI, the spoke polls for its token
|
|
3
|
+
// - device pairing: short single-use numeric codes, shown in the /hub dialog or logged at startup
|
|
4
|
+
// - login links: long single-use codes opened from the terminal to sign a browser in
|
|
5
|
+
// Pending codes live in memory only; a restart invalidates them.
|
|
6
|
+
import { randomBytes, randomInt } from "node:crypto"
|
|
7
|
+
import type { EnrollPoll, EnrollStart } from "../protocol"
|
|
8
|
+
import { LOCAL_USER, newToken, type DeviceRecord, type Store } from "./store"
|
|
9
|
+
|
|
10
|
+
export class RateLimitError extends Error {
|
|
11
|
+
constructor(message = "Too many attempts; try again in a minute") {
|
|
12
|
+
super(message)
|
|
13
|
+
this.name = "RateLimitError"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class ValidationError extends Error {
|
|
18
|
+
constructor(message: string) {
|
|
19
|
+
super(message)
|
|
20
|
+
this.name = "ValidationError"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Sliding-window counter. */
|
|
25
|
+
export class Limiter {
|
|
26
|
+
private hits: number[] = []
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
private readonly max: number,
|
|
30
|
+
private readonly windowMs: number,
|
|
31
|
+
private readonly now: () => number = Date.now,
|
|
32
|
+
) {}
|
|
33
|
+
|
|
34
|
+
/** True when another hit is allowed right now (does not record one). */
|
|
35
|
+
allowed(): boolean {
|
|
36
|
+
const cutoff = this.now() - this.windowMs
|
|
37
|
+
this.hits = this.hits.filter((t) => t > cutoff)
|
|
38
|
+
return this.hits.length < this.max
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
hit(): void {
|
|
42
|
+
this.hits.push(this.now())
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Records a hit if allowed. */
|
|
46
|
+
tryHit(): boolean {
|
|
47
|
+
if (!this.allowed()) return false
|
|
48
|
+
this.hit()
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const ENROLL_TTL_MS = 10 * 60_000
|
|
54
|
+
export const ENROLL_INTERVAL_MS = 2000
|
|
55
|
+
export const PAIR_TTL_MS = 5 * 60_000
|
|
56
|
+
export const LOGIN_TTL_MS = 2 * 60_000
|
|
57
|
+
|
|
58
|
+
/** RFC 8628 base-20 alphabet: no vowels, no look-alike characters. */
|
|
59
|
+
const USER_CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ"
|
|
60
|
+
|
|
61
|
+
function userCode(): string {
|
|
62
|
+
let out = ""
|
|
63
|
+
for (let i = 0; i < 8; i++) out += USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)]
|
|
64
|
+
return `${out.slice(0, 4)}-${out.slice(4)}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Accepts "abcd-efgh", "ABCDEFGH", " abcd efgh ". */
|
|
68
|
+
function normalizeUserCode(code: string): string {
|
|
69
|
+
const raw = code.toUpperCase().replace(/[^A-Z]/g, "")
|
|
70
|
+
return raw.length === 8 ? `${raw.slice(0, 4)}-${raw.slice(4)}` : raw
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function name(value: unknown, what: string, fallback?: string): string {
|
|
74
|
+
const text = typeof value === "string" ? value.trim().replace(/\s+/g, " ") : ""
|
|
75
|
+
if (!text && fallback !== undefined) return fallback
|
|
76
|
+
if (!text || text.length > 64) throw new ValidationError(`Invalid ${what}: 1-64 characters required`)
|
|
77
|
+
return text
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface Enrollment {
|
|
81
|
+
userCode: string
|
|
82
|
+
deviceCode: string
|
|
83
|
+
machineName: string
|
|
84
|
+
created: number
|
|
85
|
+
expiresAt: number
|
|
86
|
+
status: "pending" | "approved" | "denied"
|
|
87
|
+
/** The approver: owner of the machine created on the next poll. */
|
|
88
|
+
ownerID?: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A pending pairing code or login link and the user its device will belong to. */
|
|
92
|
+
interface Grant {
|
|
93
|
+
readonly expiresAt: number
|
|
94
|
+
readonly ownerID: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface PendingEnrollment {
|
|
98
|
+
readonly userCode: string
|
|
99
|
+
readonly machineName: string
|
|
100
|
+
readonly created: number
|
|
101
|
+
readonly expiresAt: number
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface AuthOptions {
|
|
105
|
+
readonly store: Store
|
|
106
|
+
readonly publicURL: string
|
|
107
|
+
readonly now?: () => number
|
|
108
|
+
/** oidc: no bootstrap pairing code (devices are created by sign-in or by a machine owner). */
|
|
109
|
+
readonly mode?: "pairing" | "oidc"
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class Auth {
|
|
113
|
+
private readonly store: Store
|
|
114
|
+
private readonly publicURL: string
|
|
115
|
+
private readonly now: () => number
|
|
116
|
+
private readonly enrollments = new Map<string, Enrollment>() // by deviceCode
|
|
117
|
+
private readonly pairCodes = new Map<string, Grant>()
|
|
118
|
+
private readonly loginCodes = new Map<string, Grant>()
|
|
119
|
+
private readonly mode: "pairing" | "oidc"
|
|
120
|
+
private readonly enrollLimiter: Limiter
|
|
121
|
+
private readonly pairFailures: Limiter
|
|
122
|
+
|
|
123
|
+
constructor(options: AuthOptions) {
|
|
124
|
+
this.store = options.store
|
|
125
|
+
this.publicURL = options.publicURL
|
|
126
|
+
this.now = options.now ?? Date.now
|
|
127
|
+
this.mode = options.mode ?? "pairing"
|
|
128
|
+
this.enrollLimiter = new Limiter(5, 60_000, this.now)
|
|
129
|
+
this.pairFailures = new Limiter(10, 60_000, this.now)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------- machine enrollment ----------
|
|
133
|
+
|
|
134
|
+
startEnrollment(machineName: unknown): EnrollStart {
|
|
135
|
+
const machine = name(machineName, "machine name")
|
|
136
|
+
if (!this.enrollLimiter.tryHit()) throw new RateLimitError("Too many enrollment requests; try again in a minute")
|
|
137
|
+
this.sweep()
|
|
138
|
+
const taken = new Set([...this.enrollments.values()].map((e) => e.userCode))
|
|
139
|
+
let code = userCode()
|
|
140
|
+
while (taken.has(code)) code = userCode()
|
|
141
|
+
const created = this.now()
|
|
142
|
+
const enrollment: Enrollment = {
|
|
143
|
+
userCode: code,
|
|
144
|
+
deviceCode: newToken(),
|
|
145
|
+
machineName: machine,
|
|
146
|
+
created,
|
|
147
|
+
expiresAt: created + ENROLL_TTL_MS,
|
|
148
|
+
status: "pending",
|
|
149
|
+
}
|
|
150
|
+
this.enrollments.set(enrollment.deviceCode, enrollment)
|
|
151
|
+
return {
|
|
152
|
+
userCode: code,
|
|
153
|
+
deviceCode: enrollment.deviceCode,
|
|
154
|
+
approveURL: `${this.publicURL}/approve?code=${code}`,
|
|
155
|
+
intervalMs: ENROLL_INTERVAL_MS,
|
|
156
|
+
expiresAt: enrollment.expiresAt,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Approved and denied results are delivered once; afterwards the code reads as expired. */
|
|
161
|
+
pollEnrollment(deviceCode: unknown): EnrollPoll {
|
|
162
|
+
this.sweep()
|
|
163
|
+
const enrollment = typeof deviceCode === "string" ? this.enrollments.get(deviceCode) : undefined
|
|
164
|
+
if (!enrollment) return { status: "expired" }
|
|
165
|
+
if (enrollment.status === "pending") return { status: "pending" }
|
|
166
|
+
this.enrollments.delete(enrollment.deviceCode)
|
|
167
|
+
if (enrollment.status === "denied") return { status: "denied" }
|
|
168
|
+
const { machine, token } = this.store.addMachine(enrollment.machineName, enrollment.ownerID ?? LOCAL_USER)
|
|
169
|
+
return { status: "approved", machineID: machine.id, machineToken: token }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
pendingEnrollments(): PendingEnrollment[] {
|
|
173
|
+
this.sweep()
|
|
174
|
+
return [...this.enrollments.values()]
|
|
175
|
+
.filter((e) => e.status === "pending")
|
|
176
|
+
.map(({ userCode, machineName, created, expiresAt }) => ({ userCode, machineName, created, expiresAt }))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Approves a pending enrollment; `ownerID` (the approver) will own the machine. */
|
|
180
|
+
approveEnrollment(code: string, ownerID: string = LOCAL_USER): boolean {
|
|
181
|
+
return this.decide(code, "approved", ownerID) !== undefined
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
denyEnrollment(code: string): boolean {
|
|
185
|
+
return this.decide(code, "denied") !== undefined
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Approves or denies a pending enrollment; the machine name, or undefined when no such code is pending. */
|
|
189
|
+
decide(code: string, status: "approved" | "denied", ownerID: string = LOCAL_USER): string | undefined {
|
|
190
|
+
this.sweep()
|
|
191
|
+
const wanted = normalizeUserCode(code)
|
|
192
|
+
for (const enrollment of this.enrollments.values()) {
|
|
193
|
+
if (enrollment.userCode === wanted && enrollment.status === "pending") {
|
|
194
|
+
enrollment.status = status
|
|
195
|
+
if (status === "approved") enrollment.ownerID = ownerID
|
|
196
|
+
return enrollment.machineName
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return undefined
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------- device pairing ----------
|
|
203
|
+
|
|
204
|
+
/** A pairing code; the device it pairs belongs to `ownerID`. */
|
|
205
|
+
issuePairCode(ownerID: string = LOCAL_USER): { code: string; expiresAt: number } {
|
|
206
|
+
this.sweep()
|
|
207
|
+
let code = String(randomInt(1_000_000)).padStart(6, "0")
|
|
208
|
+
while (this.pairCodes.has(code)) code = String(randomInt(1_000_000)).padStart(6, "0")
|
|
209
|
+
const expiresAt = this.now() + PAIR_TTL_MS
|
|
210
|
+
this.pairCodes.set(code, { expiresAt, ownerID })
|
|
211
|
+
return { code, expiresAt }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A pairing code for the first device (pairing mode only), or undefined once any device exists. */
|
|
215
|
+
bootstrapCode(): { code: string; expiresAt: number } | undefined {
|
|
216
|
+
return this.mode === "pairing" && this.store.devices().length === 0 ? this.issuePairCode(LOCAL_USER) : undefined
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Redeems a pairing code. Throws RateLimitError after too many recent failures. A code whose owner
|
|
221
|
+
* is gone or disabled counts as invalid.
|
|
222
|
+
*/
|
|
223
|
+
pair(code: unknown, deviceName: unknown): { device: DeviceRecord; token: string } | undefined {
|
|
224
|
+
if (!this.pairFailures.allowed()) throw new RateLimitError()
|
|
225
|
+
this.sweep()
|
|
226
|
+
const key = typeof code === "string" ? code.trim() : ""
|
|
227
|
+
const grant = this.pairCodes.get(key)
|
|
228
|
+
if (!grant) {
|
|
229
|
+
this.pairFailures.hit()
|
|
230
|
+
return undefined
|
|
231
|
+
}
|
|
232
|
+
this.pairCodes.delete(key)
|
|
233
|
+
if (!this.active(grant.ownerID)) return undefined
|
|
234
|
+
return this.store.addDevice(name(deviceName, "device name", "Device"), grant.ownerID)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------- login links ----------
|
|
238
|
+
|
|
239
|
+
/** A single-use login link code; it signs a browser in as `ownerID`. */
|
|
240
|
+
issueLoginCode(ownerID: string = LOCAL_USER): { code: string; expiresAt: number } {
|
|
241
|
+
const code = randomBytes(32).toString("base64url")
|
|
242
|
+
const expiresAt = this.now() + LOGIN_TTL_MS
|
|
243
|
+
this.loginCodes.set(code, { expiresAt, ownerID })
|
|
244
|
+
return { code, expiresAt }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Redeems a login code: the user to sign in as, or undefined (unknown, used, expired, or owner disabled). */
|
|
248
|
+
redeemLogin(code: unknown): string | undefined {
|
|
249
|
+
this.sweep()
|
|
250
|
+
const grant = typeof code === "string" ? this.loginCodes.get(code) : undefined
|
|
251
|
+
if (!grant) return undefined
|
|
252
|
+
this.loginCodes.delete(code as string)
|
|
253
|
+
return this.active(grant.ownerID) ? grant.ownerID : undefined
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
redeemLoginCode(code: unknown): boolean {
|
|
257
|
+
return this.redeemLogin(code) !== undefined
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private active(userID: string): boolean {
|
|
261
|
+
const user = this.store.user(userID)
|
|
262
|
+
return user !== undefined && !user.disabled
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private sweep(): void {
|
|
266
|
+
const t = this.now()
|
|
267
|
+
for (const [key, e] of this.enrollments) if (e.expiresAt < t) this.enrollments.delete(key)
|
|
268
|
+
for (const [key, grant] of this.pairCodes) if (grant.expiresAt < t) this.pairCodes.delete(key)
|
|
269
|
+
for (const [key, grant] of this.loginCodes) if (grant.expiresAt < t) this.loginCodes.delete(key)
|
|
270
|
+
}
|
|
271
|
+
}
|