@huynle/hub 0.6.1 → 0.7.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.
- package/CHANGELOG.md +800 -0
- package/Dockerfile +30 -0
- package/README.md +870 -66
- package/package.json +45 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +155 -0
- package/packages/cli/dist/hub-cli.js +10162 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +122 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +249 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +100 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +60 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +502 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +106 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +67 -0
- package/packages/sdk/src/types.ts +458 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +79 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +142 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +185 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +72 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +389 -0
- package/src/central/agent/routes.ts +278 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +221 -0
- package/src/central/agent/webhook-routes.ts +78 -0
- package/src/central/agent/webhooks.ts +449 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +224 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +113 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +244 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1067 -171
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +572 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +49 -1
- package/src/index.ts +165 -12
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +68 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-D9zMbxSg.css +1 -0
- package/web/dist/assets/index-db1z4duA.js +20 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/index.html +9 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
package/src/central/auth.ts
CHANGED
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
// - machine enrollment (device-code flow): a spoke asks, the owner approves in the web UI, the spoke polls for its token
|
|
3
3
|
// - device pairing: short single-use numeric codes, shown in the /hub dialog or logged at startup
|
|
4
4
|
// - login links: long single-use codes opened from the terminal to sign a browser in
|
|
5
|
-
//
|
|
5
|
+
// - app link codes: long single-use codes (QR / hubapp:// deep link) a native app redeems for a device token
|
|
6
|
+
// Pending codes, enrollments and the limiters live in the Kv / Counters backends (process memory by default, so a
|
|
7
|
+
// restart invalidates them; Valkey in a cluster, so a code issued on one pod is redeemed on any). Codes are spent
|
|
8
|
+
// with GETDEL: exactly one redeem wins, on any pod.
|
|
6
9
|
import { randomBytes, randomInt } from "node:crypto"
|
|
7
10
|
import type { EnrollPoll, EnrollStart } from "../protocol"
|
|
11
|
+
import { MemoryCounters } from "./backend/memory-counters"
|
|
12
|
+
import { MemoryKv } from "./backend/memory-kv"
|
|
13
|
+
import type { Counters, Kv } from "./backend/types"
|
|
8
14
|
import { LOCAL_USER, newToken, type DeviceRecord, type Store } from "./store"
|
|
9
15
|
|
|
10
16
|
export class RateLimitError extends Error {
|
|
@@ -21,39 +27,13 @@ export class ValidationError extends Error {
|
|
|
21
27
|
}
|
|
22
28
|
}
|
|
23
29
|
|
|
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
30
|
export const ENROLL_TTL_MS = 10 * 60_000
|
|
54
31
|
export const ENROLL_INTERVAL_MS = 2000
|
|
55
32
|
export const PAIR_TTL_MS = 5 * 60_000
|
|
56
33
|
export const LOGIN_TTL_MS = 2 * 60_000
|
|
34
|
+
export const LINK_TTL_MS = 5 * 60_000
|
|
35
|
+
/** Longest device public key accepted at link time (characters). */
|
|
36
|
+
export const MAX_PUBLIC_KEY = 4096
|
|
57
37
|
|
|
58
38
|
/** RFC 8628 base-20 alphabet: no vowels, no look-alike characters. */
|
|
59
39
|
const USER_CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ"
|
|
@@ -77,18 +57,37 @@ function name(value: unknown, what: string, fallback?: string): string {
|
|
|
77
57
|
return text
|
|
78
58
|
}
|
|
79
59
|
|
|
60
|
+
/** A device name from a client: control and format characters dropped, whitespace collapsed, ≤ 64 characters. */
|
|
61
|
+
function deviceLabel(value: unknown, fallback: string): string {
|
|
62
|
+
const text = typeof value === "string" ? value.replace(/[\p{Cc}\p{Cf}]/gu, " ").trim().replace(/\s+/g, " ") : ""
|
|
63
|
+
return text ? [...text].slice(0, 64).join("").trim() : fallback
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const PEM = /^-----BEGIN PUBLIC KEY-----([\s\S]*)-----END PUBLIC KEY-----$/
|
|
67
|
+
const KEY_BODY = /^[A-Za-z0-9+/_-]{16,}={0,2}$/
|
|
68
|
+
|
|
69
|
+
/** An optional device public key: PEM ("PUBLIC KEY") or bare base64/base64url; undefined when absent. */
|
|
70
|
+
function publicKey(value: unknown): string | undefined {
|
|
71
|
+
if (value === undefined || value === null || value === "") return undefined
|
|
72
|
+
if (typeof value !== "string" || value.length > MAX_PUBLIC_KEY) throw new ValidationError("Invalid public key")
|
|
73
|
+
const key = value.trim()
|
|
74
|
+
const body = (PEM.exec(key)?.[1] ?? key).replace(/\s+/g, "")
|
|
75
|
+
if (!KEY_BODY.test(body)) throw new ValidationError("Invalid public key")
|
|
76
|
+
return key
|
|
77
|
+
}
|
|
78
|
+
|
|
80
79
|
interface Enrollment {
|
|
81
|
-
userCode: string
|
|
82
|
-
deviceCode: string
|
|
83
|
-
machineName: string
|
|
84
|
-
created: number
|
|
85
|
-
expiresAt: number
|
|
86
|
-
status: "pending" | "approved" | "denied"
|
|
80
|
+
readonly userCode: string
|
|
81
|
+
readonly deviceCode: string
|
|
82
|
+
readonly machineName: string
|
|
83
|
+
readonly created: number
|
|
84
|
+
readonly expiresAt: number
|
|
85
|
+
readonly status: "pending" | "approved" | "denied"
|
|
87
86
|
/** The approver: owner of the machine created on the next poll. */
|
|
88
|
-
ownerID?: string
|
|
87
|
+
readonly ownerID?: string
|
|
89
88
|
}
|
|
90
89
|
|
|
91
|
-
/** A pending pairing code or
|
|
90
|
+
/** A pending pairing code, login link or app link code and the user its device will belong to. */
|
|
92
91
|
interface Grant {
|
|
93
92
|
readonly expiresAt: number
|
|
94
93
|
readonly ownerID: string
|
|
@@ -107,108 +106,167 @@ export interface AuthOptions {
|
|
|
107
106
|
readonly now?: () => number
|
|
108
107
|
/** oidc: no bootstrap pairing code (devices are created by sign-in or by a machine owner). */
|
|
109
108
|
readonly mode?: "pairing" | "oidc"
|
|
109
|
+
/** Pending codes and enrollments (default: in this process, on `now`). */
|
|
110
|
+
readonly kv?: Kv
|
|
111
|
+
/** The enrollment and pairing-failure limits (default: in this process, on `now`). */
|
|
112
|
+
readonly counters?: Counters
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Enrollments started per window (every pod together). */
|
|
116
|
+
const ENROLL_MAX = 5
|
|
117
|
+
const ENROLL_WINDOW_MS = 60_000
|
|
118
|
+
/** Failed pairing attempts per window before /api/pair refuses (every pod together). */
|
|
119
|
+
const PAIR_FAILURES_MAX = 10
|
|
120
|
+
const PAIR_FAILURES_WINDOW_MS = 60_000
|
|
121
|
+
/**
|
|
122
|
+
* Enrollment slots the pending list reads back: more than the rate limit lets live at once (a fixed window admits
|
|
123
|
+
* up to twice its limit across a window boundary).
|
|
124
|
+
*/
|
|
125
|
+
const ENROLL_SLOTS = 2 * ENROLL_MAX * (ENROLL_TTL_MS / ENROLL_WINDOW_MS + 1)
|
|
126
|
+
/** The enrollment sequence's window: it never resets in practice. */
|
|
127
|
+
const ENROLL_SEQ_TTL_MS = 100 * 365 * 24 * 3_600_000
|
|
128
|
+
|
|
129
|
+
type CodeKind = "pair" | "login" | "link"
|
|
130
|
+
|
|
131
|
+
const KEYS = {
|
|
132
|
+
enroll: (deviceCode: string) => `auth:enroll:${deviceCode}`,
|
|
133
|
+
enrollCode: (userCode: string) => `auth:enroll-code:${userCode}`,
|
|
134
|
+
enrollDecided: (deviceCode: string) => `auth:enroll-decided:${deviceCode}`,
|
|
135
|
+
enrollSlot: (slot: number) => `auth:enroll-slot:${slot}`,
|
|
136
|
+
enrollSeq: "auth:enroll-seq",
|
|
137
|
+
enrollRate: "auth:enroll-rate",
|
|
138
|
+
pairFailures: "auth:pair-failures",
|
|
139
|
+
code: (kind: CodeKind, code: string) => `auth:${kind}:${code}`,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parse<T>(raw: string | undefined): T | undefined {
|
|
143
|
+
if (raw === undefined) return undefined
|
|
144
|
+
try {
|
|
145
|
+
return JSON.parse(raw) as T
|
|
146
|
+
} catch {
|
|
147
|
+
return undefined
|
|
148
|
+
}
|
|
110
149
|
}
|
|
111
150
|
|
|
112
151
|
export class Auth {
|
|
113
152
|
private readonly store: Store
|
|
114
153
|
private readonly publicURL: string
|
|
115
154
|
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
155
|
private readonly mode: "pairing" | "oidc"
|
|
120
|
-
private readonly
|
|
121
|
-
private readonly
|
|
156
|
+
private readonly kv: Kv
|
|
157
|
+
private readonly counters: Counters
|
|
158
|
+
/** Pairing codes this process issued and their expiry: a new code never repeats one of them. */
|
|
159
|
+
private readonly issuedPairCodes = new Map<string, number>()
|
|
160
|
+
/** Code writes in flight (codes are issued synchronously); a redeem here waits for them. */
|
|
161
|
+
private readonly writes = new Set<Promise<void>>()
|
|
122
162
|
|
|
123
163
|
constructor(options: AuthOptions) {
|
|
124
164
|
this.store = options.store
|
|
125
165
|
this.publicURL = options.publicURL
|
|
126
166
|
this.now = options.now ?? Date.now
|
|
127
167
|
this.mode = options.mode ?? "pairing"
|
|
128
|
-
this.
|
|
129
|
-
this.
|
|
168
|
+
this.kv = options.kv ?? new MemoryKv({ now: this.now })
|
|
169
|
+
this.counters = options.counters ?? new MemoryCounters({ now: this.now })
|
|
130
170
|
}
|
|
131
171
|
|
|
132
172
|
// ---------- machine enrollment ----------
|
|
133
173
|
|
|
134
|
-
startEnrollment(machineName: unknown): EnrollStart {
|
|
174
|
+
async startEnrollment(machineName: unknown): Promise<EnrollStart> {
|
|
135
175
|
const machine = name(machineName, "machine name")
|
|
136
|
-
if (
|
|
137
|
-
|
|
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",
|
|
176
|
+
if ((await this.counters.incr(KEYS.enrollRate, ENROLL_WINDOW_MS)) > ENROLL_MAX) {
|
|
177
|
+
throw new RateLimitError("Too many enrollment requests; try again in a minute")
|
|
149
178
|
}
|
|
150
|
-
this.
|
|
179
|
+
const created = this.now()
|
|
180
|
+
const expiresAt = created + ENROLL_TTL_MS
|
|
181
|
+
const px = ENROLL_TTL_MS + 1
|
|
182
|
+
const deviceCode = newToken()
|
|
183
|
+
let code = userCode()
|
|
184
|
+
while (!(await this.kv.set(KEYS.enrollCode(code), deviceCode, { px, nx: true }))) code = userCode()
|
|
185
|
+
const enrollment: Enrollment = { userCode: code, deviceCode, machineName: machine, created, expiresAt, status: "pending" }
|
|
186
|
+
await this.kv.set(KEYS.enroll(deviceCode), JSON.stringify(enrollment), { px })
|
|
187
|
+
const slot = await this.counters.incr(KEYS.enrollSeq, ENROLL_SEQ_TTL_MS)
|
|
188
|
+
await this.kv.set(KEYS.enrollSlot(slot), deviceCode, { px })
|
|
151
189
|
return {
|
|
152
190
|
userCode: code,
|
|
153
|
-
deviceCode
|
|
191
|
+
deviceCode,
|
|
154
192
|
approveURL: `${this.publicURL}/approve?code=${code}`,
|
|
155
193
|
intervalMs: ENROLL_INTERVAL_MS,
|
|
156
|
-
expiresAt
|
|
194
|
+
expiresAt,
|
|
157
195
|
}
|
|
158
196
|
}
|
|
159
197
|
|
|
160
|
-
/** Approved and denied results are delivered once; afterwards the code reads as expired. */
|
|
161
|
-
pollEnrollment(deviceCode: unknown): EnrollPoll {
|
|
162
|
-
this.
|
|
163
|
-
const enrollment = typeof deviceCode === "string" ? this.enrollments.get(deviceCode) : undefined
|
|
198
|
+
/** Approved and denied results are delivered once (GETDEL); afterwards the code reads as expired. */
|
|
199
|
+
async pollEnrollment(deviceCode: unknown): Promise<EnrollPoll> {
|
|
200
|
+
const enrollment = typeof deviceCode === "string" ? await this.enrollment(deviceCode) : undefined
|
|
164
201
|
if (!enrollment) return { status: "expired" }
|
|
165
202
|
if (enrollment.status === "pending") return { status: "pending" }
|
|
166
|
-
this.
|
|
167
|
-
if (
|
|
168
|
-
|
|
203
|
+
const taken = parse<Enrollment>(await this.kv.getdel(KEYS.enroll(enrollment.deviceCode)))
|
|
204
|
+
if (!taken || taken.status === "pending") return { status: "expired" }
|
|
205
|
+
await this.kv.del(KEYS.enrollCode(taken.userCode))
|
|
206
|
+
if (taken.status === "denied") return { status: "denied" }
|
|
207
|
+
const { machine, token } = await this.store.addMachine(taken.machineName, taken.ownerID ?? LOCAL_USER)
|
|
169
208
|
return { status: "approved", machineID: machine.id, machineToken: token }
|
|
170
209
|
}
|
|
171
210
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
211
|
+
/** Pending enrollments, oldest first. */
|
|
212
|
+
async pendingEnrollments(): Promise<PendingEnrollment[]> {
|
|
213
|
+
const last = await this.counters.get(KEYS.enrollSeq)
|
|
214
|
+
const slots: number[] = []
|
|
215
|
+
for (let slot = Math.max(1, last - ENROLL_SLOTS + 1); slot <= last; slot++) slots.push(slot)
|
|
216
|
+
const deviceCodes = await Promise.all(slots.map((slot) => this.kv.get(KEYS.enrollSlot(slot))))
|
|
217
|
+
const unique = [...new Set(deviceCodes.filter((code): code is string => code !== undefined))]
|
|
218
|
+
const enrollments = await Promise.all(unique.map((code) => this.enrollment(code)))
|
|
219
|
+
return enrollments
|
|
220
|
+
.filter((e): e is Enrollment => e?.status === "pending")
|
|
176
221
|
.map(({ userCode, machineName, created, expiresAt }) => ({ userCode, machineName, created, expiresAt }))
|
|
177
222
|
}
|
|
178
223
|
|
|
179
224
|
/** 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
|
|
225
|
+
async approveEnrollment(code: string, ownerID: string = LOCAL_USER): Promise<boolean> {
|
|
226
|
+
return (await this.decide(code, "approved", ownerID)) !== undefined
|
|
182
227
|
}
|
|
183
228
|
|
|
184
|
-
denyEnrollment(code: string): boolean {
|
|
185
|
-
return this.decide(code, "denied") !== undefined
|
|
229
|
+
async denyEnrollment(code: string): Promise<boolean> {
|
|
230
|
+
return (await this.decide(code, "denied")) !== undefined
|
|
186
231
|
}
|
|
187
232
|
|
|
188
|
-
/**
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
|
|
233
|
+
/**
|
|
234
|
+
* Approves or denies a pending enrollment; the machine name, or undefined when no such code is pending. The
|
|
235
|
+
* first decision wins (SET NX), on any pod.
|
|
236
|
+
*/
|
|
237
|
+
async decide(code: string, status: "approved" | "denied", ownerID: string = LOCAL_USER): Promise<string | undefined> {
|
|
238
|
+
const deviceCode = await this.kv.get(KEYS.enrollCode(normalizeUserCode(code)))
|
|
239
|
+
const enrollment = deviceCode === undefined ? undefined : await this.enrollment(deviceCode)
|
|
240
|
+
if (!enrollment || enrollment.status !== "pending") return undefined
|
|
241
|
+
const px = this.remaining(enrollment.expiresAt)
|
|
242
|
+
if (!(await this.kv.set(KEYS.enrollDecided(enrollment.deviceCode), status, { px, nx: true }))) return undefined
|
|
243
|
+
const decided: Enrollment = { ...enrollment, status, ...(status === "approved" ? { ownerID } : {}) }
|
|
244
|
+
await this.kv.set(KEYS.enroll(enrollment.deviceCode), JSON.stringify(decided), { px })
|
|
245
|
+
return enrollment.machineName
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The enrollment of a device code, unless it expired. */
|
|
249
|
+
private async enrollment(deviceCode: string): Promise<Enrollment | undefined> {
|
|
250
|
+
const enrollment = parse<Enrollment>(await this.kv.get(KEYS.enroll(deviceCode)))
|
|
251
|
+
return enrollment && this.remaining(enrollment.expiresAt) > 0 ? enrollment : undefined
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Milliseconds a record expiring at `expiresAt` is still valid for (valid through expiresAt itself). */
|
|
255
|
+
private remaining(expiresAt: number): number {
|
|
256
|
+
return expiresAt - this.now() + 1
|
|
200
257
|
}
|
|
201
258
|
|
|
202
259
|
// ---------- device pairing ----------
|
|
203
260
|
|
|
204
261
|
/** A pairing code; the device it pairs belongs to `ownerID`. */
|
|
205
262
|
issuePairCode(ownerID: string = LOCAL_USER): { code: string; expiresAt: number } {
|
|
206
|
-
this.
|
|
263
|
+
const t = this.now()
|
|
264
|
+
for (const [code, expiresAt] of this.issuedPairCodes) if (expiresAt < t) this.issuedPairCodes.delete(code)
|
|
207
265
|
let code = String(randomInt(1_000_000)).padStart(6, "0")
|
|
208
|
-
while (this.
|
|
209
|
-
const
|
|
210
|
-
this.
|
|
211
|
-
return
|
|
266
|
+
while (this.issuedPairCodes.has(code)) code = String(randomInt(1_000_000)).padStart(6, "0")
|
|
267
|
+
const issued = this.issue("pair", code, ownerID, PAIR_TTL_MS)
|
|
268
|
+
this.issuedPairCodes.set(code, issued.expiresAt)
|
|
269
|
+
return issued
|
|
212
270
|
}
|
|
213
271
|
|
|
214
272
|
/** A pairing code for the first device (pairing mode only), or undefined once any device exists. */
|
|
@@ -220,52 +278,97 @@ export class Auth {
|
|
|
220
278
|
* Redeems a pairing code. Throws RateLimitError after too many recent failures. A code whose owner
|
|
221
279
|
* is gone or disabled counts as invalid.
|
|
222
280
|
*/
|
|
223
|
-
pair(code: unknown, deviceName: unknown): { device: DeviceRecord; token: string } | undefined {
|
|
224
|
-
if (
|
|
225
|
-
this.
|
|
226
|
-
const key = typeof code === "string" ? code.trim() : ""
|
|
227
|
-
const grant = this.pairCodes.get(key)
|
|
281
|
+
async pair(code: unknown, deviceName: unknown): Promise<{ device: DeviceRecord; token: string } | undefined> {
|
|
282
|
+
if ((await this.counters.get(KEYS.pairFailures)) >= PAIR_FAILURES_MAX) throw new RateLimitError()
|
|
283
|
+
const grant = await this.redeem("pair", typeof code === "string" ? code.trim() : "")
|
|
228
284
|
if (!grant) {
|
|
229
|
-
this.
|
|
285
|
+
await this.counters.incr(KEYS.pairFailures, PAIR_FAILURES_WINDOW_MS)
|
|
230
286
|
return undefined
|
|
231
287
|
}
|
|
232
|
-
this.pairCodes.delete(key)
|
|
233
288
|
if (!this.active(grant.ownerID)) return undefined
|
|
234
|
-
return this.store.addDevice(name(deviceName, "device name", "Device"), grant.ownerID)
|
|
289
|
+
return await this.store.addDevice(name(deviceName, "device name", "Device"), grant.ownerID)
|
|
235
290
|
}
|
|
236
291
|
|
|
237
292
|
// ---------- login links ----------
|
|
238
293
|
|
|
239
294
|
/** A single-use login link code; it signs a browser in as `ownerID`. */
|
|
240
295
|
issueLoginCode(ownerID: string = LOCAL_USER): { code: string; expiresAt: number } {
|
|
241
|
-
|
|
242
|
-
const expiresAt = this.now() + LOGIN_TTL_MS
|
|
243
|
-
this.loginCodes.set(code, { expiresAt, ownerID })
|
|
244
|
-
return { code, expiresAt }
|
|
296
|
+
return this.issue("login", randomBytes(32).toString("base64url"), ownerID, LOGIN_TTL_MS)
|
|
245
297
|
}
|
|
246
298
|
|
|
247
299
|
/** 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.
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
300
|
+
async redeemLogin(code: unknown): Promise<string | undefined> {
|
|
301
|
+
const grant = await this.redeem("login", code)
|
|
302
|
+
return grant && this.active(grant.ownerID) ? grant.ownerID : undefined
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async redeemLoginCode(code: unknown): Promise<boolean> {
|
|
306
|
+
return (await this.redeemLogin(code)) !== undefined
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ---------- app link codes ----------
|
|
310
|
+
|
|
311
|
+
/** A single-use app link code; the device it links belongs to `ownerID`. Never valid at /login or /api/pair. */
|
|
312
|
+
issueLinkCode(ownerID: string = LOCAL_USER): { code: string; expiresAt: number } {
|
|
313
|
+
return this.issue("link", randomBytes(32).toString("base64url"), ownerID, LINK_TTL_MS)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Redeems an app link code for a new device and its token, or undefined (unknown, used, expired, or owner
|
|
318
|
+
* gone or disabled). Throws ValidationError for a malformed public key (the code stays valid). The code is
|
|
319
|
+
* deleted before the device is created. No rate limiter: 256-bit codes cannot be guessed, and a shared
|
|
320
|
+
* limiter would let bad redeems lock out pairing.
|
|
321
|
+
*/
|
|
322
|
+
async redeemLink(code: unknown, deviceName: unknown, key?: unknown): Promise<{ device: DeviceRecord; token: string } | undefined> {
|
|
323
|
+
const label = deviceLabel(deviceName, "App")
|
|
324
|
+
const pk = publicKey(key)
|
|
325
|
+
const grant = await this.redeem("link", code)
|
|
326
|
+
if (!grant || !this.active(grant.ownerID)) return undefined
|
|
327
|
+
return await this.store.addDevice(label, grant.ownerID, pk ? { publicKey: pk } : {})
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---------- pending codes ----------
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Issues a code at once and stores it in the background (callers are synchronous); a redeem in this process
|
|
334
|
+
* waits for the write, and another pod's redeem comes after a person typed or opened the code. Should the
|
|
335
|
+
* code already exist (another pod issued the same pairing code), both are dropped: a code never signs anyone
|
|
336
|
+
* in as someone else.
|
|
337
|
+
*/
|
|
338
|
+
private issue(kind: CodeKind, code: string, ownerID: string, ttlMs: number): { code: string; expiresAt: number } {
|
|
339
|
+
const expiresAt = this.now() + ttlMs
|
|
340
|
+
const key = KEYS.code(kind, code)
|
|
341
|
+
const grant: Grant = { expiresAt, ownerID }
|
|
342
|
+
this.track(
|
|
343
|
+
this.kv.set(key, JSON.stringify(grant), { px: ttlMs + 1, nx: true }).then(async (stored) => {
|
|
344
|
+
if (stored) return
|
|
345
|
+
await this.kv.del(key)
|
|
346
|
+
console.warn(`[hub] a new ${kind} code collided with a pending one; both were dropped`)
|
|
347
|
+
}),
|
|
348
|
+
)
|
|
349
|
+
return { code, expiresAt }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Spends a code (GETDEL): its grant, or undefined (unknown, used, expired). */
|
|
353
|
+
private async redeem(kind: CodeKind, code: unknown): Promise<Grant | undefined> {
|
|
354
|
+
if (typeof code !== "string" || !code) return undefined
|
|
355
|
+
if (this.writes.size) await Promise.all([...this.writes])
|
|
356
|
+
const grant = parse<Grant>(await this.kv.getdel(KEYS.code(kind, code)))
|
|
357
|
+
return grant && this.remaining(grant.expiresAt) > 0 ? grant : undefined
|
|
254
358
|
}
|
|
255
359
|
|
|
256
|
-
|
|
257
|
-
|
|
360
|
+
private track(write: Promise<unknown>): void {
|
|
361
|
+
const tracked: Promise<void> = write
|
|
362
|
+
.then(
|
|
363
|
+
() => {},
|
|
364
|
+
(cause: unknown) => console.error(`[hub] pending code not stored: ${cause instanceof Error ? cause.message : cause}`),
|
|
365
|
+
)
|
|
366
|
+
.finally(() => this.writes.delete(tracked))
|
|
367
|
+
this.writes.add(tracked)
|
|
258
368
|
}
|
|
259
369
|
|
|
260
370
|
private active(userID: string): boolean {
|
|
261
371
|
const user = this.store.user(userID)
|
|
262
372
|
return user !== undefined && !user.disabled
|
|
263
373
|
}
|
|
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
374
|
}
|