@huynle/hub 0.0.0-stage → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/Dockerfile +51 -0
  3. package/LICENSE +21 -0
  4. package/README.md +1029 -2
  5. package/bin/hub.ts +20 -0
  6. package/docker/hub-deps.ts +42 -0
  7. package/package.json +85 -4
  8. package/rpc.ts +2 -0
  9. package/scripts/hub-local.sh +28 -0
  10. package/scripts/io.github.huynle.hub.plist +42 -0
  11. package/server.ts +3 -0
  12. package/src/central/assets.ts +74 -0
  13. package/src/central/auth.ts +271 -0
  14. package/src/central/config.ts +310 -0
  15. package/src/central/http.ts +1247 -0
  16. package/src/central/main.ts +136 -0
  17. package/src/central/oidc.ts +295 -0
  18. package/src/central/push-target.ts +97 -0
  19. package/src/central/push.ts +285 -0
  20. package/src/central/registry.ts +1153 -0
  21. package/src/central/speech/models.ts +89 -0
  22. package/src/central/speech/openai.ts +66 -0
  23. package/src/central/speech/openrouter.ts +71 -0
  24. package/src/central/speech/provider.ts +74 -0
  25. package/src/central/speech/service.ts +168 -0
  26. package/src/central/speech/settings.ts +375 -0
  27. package/src/central/spoke-ws.ts +300 -0
  28. package/src/central/store.ts +653 -0
  29. package/src/config.ts +170 -0
  30. package/src/env.ts +63 -0
  31. package/src/index.ts +236 -0
  32. package/src/key.ts +17 -0
  33. package/src/loopback.ts +41 -0
  34. package/src/migrate.ts +105 -0
  35. package/src/openrouter.ts +3 -0
  36. package/src/options.ts +33 -0
  37. package/src/protocol.ts +650 -0
  38. package/src/reply.ts +27 -0
  39. package/src/rpc.ts +98 -0
  40. package/src/setup.ts +124 -0
  41. package/src/speakable.ts +35 -0
  42. package/src/spoke/client.ts +272 -0
  43. package/src/spoke/conversation.ts +93 -0
  44. package/src/spoke/enroll.ts +79 -0
  45. package/src/spoke/herdr.ts +346 -0
  46. package/src/spoke/listing.ts +108 -0
  47. package/src/spoke/localhub.ts +287 -0
  48. package/src/spoke/locations.ts +34 -0
  49. package/src/spoke/own-server.ts +32 -0
  50. package/src/spoke/presence.ts +45 -0
  51. package/src/spoke/singleton.ts +103 -0
  52. package/src/spoke/spoke.ts +1212 -0
  53. package/src/spoke/summary.ts +117 -0
  54. package/src/spoke/timeline.ts +93 -0
  55. package/src/spoke/tracker.ts +345 -0
  56. package/src/spoke/watch.ts +78 -0
  57. package/src/status.ts +232 -0
  58. package/src/tui-commands.ts +53 -0
  59. package/src/tui-forms.ts +77 -0
  60. package/src/tui-presence.ts +75 -0
  61. package/src/tui.ts +479 -0
  62. package/src/turns.ts +94 -0
  63. package/src/voice-command.ts +65 -0
  64. package/tui.ts +2 -0
  65. package/web/dist/apple-touch-icon.png +0 -0
  66. package/web/dist/assets/index-DClBvfLL.css +1 -0
  67. package/web/dist/assets/index-Djo_yF0a.js +12 -0
  68. package/web/dist/favicon.ico +0 -0
  69. package/web/dist/icon-192.png +0 -0
  70. package/web/dist/icon-512.png +0 -0
  71. package/web/dist/icon-maskable-512.png +0 -0
  72. package/web/dist/icon.svg +18 -0
  73. package/web/dist/index.html +25 -0
  74. package/web/dist/manifest.webmanifest +18 -0
  75. package/web/dist/sw.js +151 -0
  76. 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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.6.1",
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,2 @@
1
+ // RPC definitions for clients, resolved by OpenCode as <dir>/rpc. VoiceRpc is the deprecated opencode-voice id.
2
+ export { HubRpc, VoiceRpc } from "./src/rpc.ts"
@@ -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,3 @@
1
+ // OpenCode resolves a local plugin directory's server entry as <dir>/server or <dir>/index;
2
+ // package.json "main" is not consulted.
3
+ export { default } from "./src/index.ts"
@@ -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
+ }