@huynle/hub 0.6.1 → 0.7.2
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 +870 -0
- package/Dockerfile +33 -0
- package/README.md +1007 -66
- package/package.json +47 -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 +156 -0
- package/packages/cli/dist/hub-cli.js +21647 -0
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -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 +123 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +252 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +104 -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 +95 -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 +553 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +107 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +70 -0
- package/packages/sdk/src/types.ts +517 -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 +101 -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 +152 -0
- package/src/central/agent/archive-inactive.ts +66 -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 +328 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +74 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -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 +385 -0
- package/src/central/agent/routes.ts +281 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +112 -0
- package/src/central/agent/webhook-routes.ts +86 -0
- package/src/central/agent/webhooks.ts +493 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +350 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +116 -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 +246 -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 +1149 -170
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/mcp-http.ts +116 -0
- 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 +625 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +118 -7
- package/src/index.ts +195 -43
- 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 +174 -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/apple-touch-icon.png +0 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-DWnMhXk9.js +20 -0
- package/web/dist/assets/index-DmUXEnw9.css +1 -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/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 +5 -5
- package/web/dist/index.html +17 -4
- package/web/dist/manifest.webmanifest +2 -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,18 @@
|
|
|
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
|
+
// - CLI login (device-code flow, `hub login`): a terminal asks, a signed-in user approves it as themselves with the
|
|
7
|
+
// scopes they choose, the terminal polls for the decision (the caller then issues an app token for that user)
|
|
8
|
+
// Pending codes, enrollments and the limiters live in the Kv / Counters backends (process memory by default, so a
|
|
9
|
+
// restart invalidates them; Valkey in a cluster, so a code issued on one pod is redeemed on any). Codes are spent
|
|
10
|
+
// with GETDEL: exactly one redeem wins, on any pod.
|
|
6
11
|
import { randomBytes, randomInt } from "node:crypto"
|
|
7
12
|
import type { EnrollPoll, EnrollStart } from "../protocol"
|
|
13
|
+
import { APP_SCOPES, DEFAULT_CLI_LOGIN_SCOPES } from "./agent/app-tokens"
|
|
14
|
+
import { MemoryCounters } from "./backend/memory-counters"
|
|
15
|
+
import { MemoryKv } from "./backend/memory-kv"
|
|
16
|
+
import type { Counters, Kv } from "./backend/types"
|
|
8
17
|
import { LOCAL_USER, newToken, type DeviceRecord, type Store } from "./store"
|
|
9
18
|
|
|
10
19
|
export class RateLimitError extends Error {
|
|
@@ -21,39 +30,14 @@ export class ValidationError extends Error {
|
|
|
21
30
|
}
|
|
22
31
|
}
|
|
23
32
|
|
|
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
33
|
export const ENROLL_TTL_MS = 10 * 60_000
|
|
54
34
|
export const ENROLL_INTERVAL_MS = 2000
|
|
55
35
|
export const PAIR_TTL_MS = 5 * 60_000
|
|
56
36
|
export const LOGIN_TTL_MS = 2 * 60_000
|
|
37
|
+
export const LINK_TTL_MS = 5 * 60_000
|
|
38
|
+
export const CLI_LOGIN_TTL_MS = 10 * 60_000
|
|
39
|
+
/** Longest device public key accepted at link time (characters). */
|
|
40
|
+
export const MAX_PUBLIC_KEY = 4096
|
|
57
41
|
|
|
58
42
|
/** RFC 8628 base-20 alphabet: no vowels, no look-alike characters. */
|
|
59
43
|
const USER_CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ"
|
|
@@ -77,18 +61,85 @@ function name(value: unknown, what: string, fallback?: string): string {
|
|
|
77
61
|
return text
|
|
78
62
|
}
|
|
79
63
|
|
|
64
|
+
/** A device name from a client: control and format characters dropped, whitespace collapsed, ≤ 64 characters. */
|
|
65
|
+
function deviceLabel(value: unknown, fallback: string): string {
|
|
66
|
+
const text = typeof value === "string" ? value.replace(/[\p{Cc}\p{Cf}]/gu, " ").trim().replace(/\s+/g, " ") : ""
|
|
67
|
+
return text ? [...text].slice(0, 64).join("").trim() : fallback
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** App token scopes from a client: a list of known scopes (deduplicated, in order); absent is none when `optional`. */
|
|
71
|
+
function scopeList(value: unknown, optional: boolean): string[] {
|
|
72
|
+
if (value === undefined && optional) return []
|
|
73
|
+
if (!Array.isArray(value) || !value.every((s): s is string => typeof s === "string")) throw new ValidationError("scopes must be a list of scope names")
|
|
74
|
+
const unknown = value.find((s) => !APP_SCOPES.includes(s))
|
|
75
|
+
if (unknown !== undefined) throw new ValidationError(`Unknown scope: ${unknown.slice(0, 64)}`)
|
|
76
|
+
return [...new Set(value)]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const PEM = /^-----BEGIN PUBLIC KEY-----([\s\S]*)-----END PUBLIC KEY-----$/
|
|
80
|
+
const KEY_BODY = /^[A-Za-z0-9+/_-]{16,}={0,2}$/
|
|
81
|
+
|
|
82
|
+
/** An optional device public key: PEM ("PUBLIC KEY") or bare base64/base64url; undefined when absent. */
|
|
83
|
+
function publicKey(value: unknown): string | undefined {
|
|
84
|
+
if (value === undefined || value === null || value === "") return undefined
|
|
85
|
+
if (typeof value !== "string" || value.length > MAX_PUBLIC_KEY) throw new ValidationError("Invalid public key")
|
|
86
|
+
const key = value.trim()
|
|
87
|
+
const body = (PEM.exec(key)?.[1] ?? key).replace(/\s+/g, "")
|
|
88
|
+
if (!KEY_BODY.test(body)) throw new ValidationError("Invalid public key")
|
|
89
|
+
return key
|
|
90
|
+
}
|
|
91
|
+
|
|
80
92
|
interface Enrollment {
|
|
81
|
-
userCode: string
|
|
82
|
-
deviceCode: string
|
|
83
|
-
machineName: string
|
|
84
|
-
created: number
|
|
85
|
-
expiresAt: number
|
|
86
|
-
status: "pending" | "approved" | "denied"
|
|
93
|
+
readonly userCode: string
|
|
94
|
+
readonly deviceCode: string
|
|
95
|
+
readonly machineName: string
|
|
96
|
+
readonly created: number
|
|
97
|
+
readonly expiresAt: number
|
|
98
|
+
readonly status: "pending" | "approved" | "denied"
|
|
87
99
|
/** The approver: owner of the machine created on the next poll. */
|
|
88
|
-
ownerID?: string
|
|
100
|
+
readonly ownerID?: string
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface CliLogin {
|
|
104
|
+
readonly userCode: string
|
|
105
|
+
readonly deviceCode: string
|
|
106
|
+
readonly clientName: string
|
|
107
|
+
/** The scopes the terminal asked for (shown on the approval page, ticked by default). */
|
|
108
|
+
readonly scopes: readonly string[]
|
|
109
|
+
readonly created: number
|
|
110
|
+
readonly expiresAt: number
|
|
111
|
+
readonly status: "pending" | "approved" | "denied"
|
|
112
|
+
/** The approver: the user the app token is issued for. */
|
|
113
|
+
readonly ownerID?: string
|
|
114
|
+
/** The scopes the approver chose. */
|
|
115
|
+
readonly approved?: readonly string[]
|
|
89
116
|
}
|
|
90
117
|
|
|
91
|
-
/**
|
|
118
|
+
/** POST /cli/login → a code to approve in the console (never a credential). */
|
|
119
|
+
export interface CliLoginStart {
|
|
120
|
+
readonly userCode: string
|
|
121
|
+
readonly deviceCode: string
|
|
122
|
+
readonly approveURL: string
|
|
123
|
+
readonly intervalMs: number
|
|
124
|
+
readonly expiresAt: number
|
|
125
|
+
readonly scopes: readonly string[]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A pending CLI login as the approval page shows it. */
|
|
129
|
+
export interface PendingCliLogin {
|
|
130
|
+
readonly userCode: string
|
|
131
|
+
readonly clientName: string
|
|
132
|
+
readonly scopes: readonly string[]
|
|
133
|
+
readonly created: number
|
|
134
|
+
readonly expiresAt: number
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The decision on a CLI login, delivered once. `approved` carries who approved it and the scopes they chose. */
|
|
138
|
+
export type CliLoginPoll =
|
|
139
|
+
| { readonly status: "pending" | "denied" | "expired" }
|
|
140
|
+
| { readonly status: "approved"; readonly ownerID: string; readonly clientName: string; readonly scopes: readonly string[] }
|
|
141
|
+
|
|
142
|
+
/** A pending pairing code, login link or app link code and the user its device will belong to. */
|
|
92
143
|
interface Grant {
|
|
93
144
|
readonly expiresAt: number
|
|
94
145
|
readonly ownerID: string
|
|
@@ -107,108 +158,241 @@ export interface AuthOptions {
|
|
|
107
158
|
readonly now?: () => number
|
|
108
159
|
/** oidc: no bootstrap pairing code (devices are created by sign-in or by a machine owner). */
|
|
109
160
|
readonly mode?: "pairing" | "oidc"
|
|
161
|
+
/** Pending codes and enrollments (default: in this process, on `now`). */
|
|
162
|
+
readonly kv?: Kv
|
|
163
|
+
/** The enrollment and pairing-failure limits (default: in this process, on `now`). */
|
|
164
|
+
readonly counters?: Counters
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Enrollments started per window (every pod together). */
|
|
168
|
+
const ENROLL_MAX = 5
|
|
169
|
+
const ENROLL_WINDOW_MS = 60_000
|
|
170
|
+
/** Failed pairing attempts per window before /api/pair refuses (every pod together). */
|
|
171
|
+
const PAIR_FAILURES_MAX = 10
|
|
172
|
+
const PAIR_FAILURES_WINDOW_MS = 60_000
|
|
173
|
+
/**
|
|
174
|
+
* Enrollment slots the pending list reads back: more than the rate limit lets live at once (a fixed window admits
|
|
175
|
+
* up to twice its limit across a window boundary).
|
|
176
|
+
*/
|
|
177
|
+
const ENROLL_SLOTS = 2 * ENROLL_MAX * (ENROLL_TTL_MS / ENROLL_WINDOW_MS + 1)
|
|
178
|
+
/** The enrollment sequence's window: it never resets in practice. */
|
|
179
|
+
const ENROLL_SEQ_TTL_MS = 100 * 365 * 24 * 3_600_000
|
|
180
|
+
|
|
181
|
+
type CodeKind = "pair" | "login" | "link"
|
|
182
|
+
|
|
183
|
+
const KEYS = {
|
|
184
|
+
enroll: (deviceCode: string) => `auth:enroll:${deviceCode}`,
|
|
185
|
+
enrollCode: (userCode: string) => `auth:enroll-code:${userCode}`,
|
|
186
|
+
enrollDecided: (deviceCode: string) => `auth:enroll-decided:${deviceCode}`,
|
|
187
|
+
enrollSlot: (slot: number) => `auth:enroll-slot:${slot}`,
|
|
188
|
+
enrollSeq: "auth:enroll-seq",
|
|
189
|
+
enrollRate: "auth:enroll-rate",
|
|
190
|
+
cliLogin: (deviceCode: string) => `auth:cli-login:${deviceCode}`,
|
|
191
|
+
cliLoginCode: (userCode: string) => `auth:cli-login-code:${userCode}`,
|
|
192
|
+
cliLoginDecided: (deviceCode: string) => `auth:cli-login-decided:${deviceCode}`,
|
|
193
|
+
cliLoginRate: "auth:cli-login-rate",
|
|
194
|
+
pairFailures: "auth:pair-failures",
|
|
195
|
+
code: (kind: CodeKind, code: string) => `auth:${kind}:${code}`,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function parse<T>(raw: string | undefined): T | undefined {
|
|
199
|
+
if (raw === undefined) return undefined
|
|
200
|
+
try {
|
|
201
|
+
return JSON.parse(raw) as T
|
|
202
|
+
} catch {
|
|
203
|
+
return undefined
|
|
204
|
+
}
|
|
110
205
|
}
|
|
111
206
|
|
|
112
207
|
export class Auth {
|
|
113
208
|
private readonly store: Store
|
|
114
209
|
private readonly publicURL: string
|
|
115
210
|
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
211
|
private readonly mode: "pairing" | "oidc"
|
|
120
|
-
private readonly
|
|
121
|
-
private readonly
|
|
212
|
+
private readonly kv: Kv
|
|
213
|
+
private readonly counters: Counters
|
|
214
|
+
/** Pairing codes this process issued and their expiry: a new code never repeats one of them. */
|
|
215
|
+
private readonly issuedPairCodes = new Map<string, number>()
|
|
216
|
+
/** Code writes in flight (codes are issued synchronously); a redeem here waits for them. */
|
|
217
|
+
private readonly writes = new Set<Promise<void>>()
|
|
122
218
|
|
|
123
219
|
constructor(options: AuthOptions) {
|
|
124
220
|
this.store = options.store
|
|
125
221
|
this.publicURL = options.publicURL
|
|
126
222
|
this.now = options.now ?? Date.now
|
|
127
223
|
this.mode = options.mode ?? "pairing"
|
|
128
|
-
this.
|
|
129
|
-
this.
|
|
224
|
+
this.kv = options.kv ?? new MemoryKv({ now: this.now })
|
|
225
|
+
this.counters = options.counters ?? new MemoryCounters({ now: this.now })
|
|
130
226
|
}
|
|
131
227
|
|
|
132
228
|
// ---------- machine enrollment ----------
|
|
133
229
|
|
|
134
|
-
startEnrollment(machineName: unknown): EnrollStart {
|
|
230
|
+
async startEnrollment(machineName: unknown): Promise<EnrollStart> {
|
|
135
231
|
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",
|
|
232
|
+
if ((await this.counters.incr(KEYS.enrollRate, ENROLL_WINDOW_MS)) > ENROLL_MAX) {
|
|
233
|
+
throw new RateLimitError("Too many enrollment requests; try again in a minute")
|
|
149
234
|
}
|
|
150
|
-
this.
|
|
235
|
+
const created = this.now()
|
|
236
|
+
const expiresAt = created + ENROLL_TTL_MS
|
|
237
|
+
const px = ENROLL_TTL_MS + 1
|
|
238
|
+
const deviceCode = newToken()
|
|
239
|
+
let code = userCode()
|
|
240
|
+
// A user code names one request: never one a CLI login holds.
|
|
241
|
+
while ((await this.kv.get(KEYS.cliLoginCode(code))) !== undefined || !(await this.kv.set(KEYS.enrollCode(code), deviceCode, { px, nx: true }))) code = userCode()
|
|
242
|
+
const enrollment: Enrollment = { userCode: code, deviceCode, machineName: machine, created, expiresAt, status: "pending" }
|
|
243
|
+
await this.kv.set(KEYS.enroll(deviceCode), JSON.stringify(enrollment), { px })
|
|
244
|
+
const slot = await this.counters.incr(KEYS.enrollSeq, ENROLL_SEQ_TTL_MS)
|
|
245
|
+
await this.kv.set(KEYS.enrollSlot(slot), deviceCode, { px })
|
|
151
246
|
return {
|
|
152
247
|
userCode: code,
|
|
153
|
-
deviceCode
|
|
248
|
+
deviceCode,
|
|
154
249
|
approveURL: `${this.publicURL}/approve?code=${code}`,
|
|
155
250
|
intervalMs: ENROLL_INTERVAL_MS,
|
|
156
|
-
expiresAt
|
|
251
|
+
expiresAt,
|
|
157
252
|
}
|
|
158
253
|
}
|
|
159
254
|
|
|
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
|
|
255
|
+
/** Approved and denied results are delivered once (GETDEL); afterwards the code reads as expired. */
|
|
256
|
+
async pollEnrollment(deviceCode: unknown): Promise<EnrollPoll> {
|
|
257
|
+
const enrollment = typeof deviceCode === "string" ? await this.enrollment(deviceCode) : undefined
|
|
164
258
|
if (!enrollment) return { status: "expired" }
|
|
165
259
|
if (enrollment.status === "pending") return { status: "pending" }
|
|
166
|
-
this.
|
|
167
|
-
if (
|
|
168
|
-
|
|
260
|
+
const taken = parse<Enrollment>(await this.kv.getdel(KEYS.enroll(enrollment.deviceCode)))
|
|
261
|
+
if (!taken || taken.status === "pending") return { status: "expired" }
|
|
262
|
+
await this.kv.del(KEYS.enrollCode(taken.userCode))
|
|
263
|
+
if (taken.status === "denied") return { status: "denied" }
|
|
264
|
+
const { machine, token } = await this.store.addMachine(taken.machineName, taken.ownerID ?? LOCAL_USER)
|
|
169
265
|
return { status: "approved", machineID: machine.id, machineToken: token }
|
|
170
266
|
}
|
|
171
267
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
268
|
+
/** Pending enrollments, oldest first. */
|
|
269
|
+
async pendingEnrollments(): Promise<PendingEnrollment[]> {
|
|
270
|
+
const last = await this.counters.get(KEYS.enrollSeq)
|
|
271
|
+
const slots: number[] = []
|
|
272
|
+
for (let slot = Math.max(1, last - ENROLL_SLOTS + 1); slot <= last; slot++) slots.push(slot)
|
|
273
|
+
const deviceCodes = await Promise.all(slots.map((slot) => this.kv.get(KEYS.enrollSlot(slot))))
|
|
274
|
+
const unique = [...new Set(deviceCodes.filter((code): code is string => code !== undefined))]
|
|
275
|
+
const enrollments = await Promise.all(unique.map((code) => this.enrollment(code)))
|
|
276
|
+
return enrollments
|
|
277
|
+
.filter((e): e is Enrollment => e?.status === "pending")
|
|
176
278
|
.map(({ userCode, machineName, created, expiresAt }) => ({ userCode, machineName, created, expiresAt }))
|
|
177
279
|
}
|
|
178
280
|
|
|
179
281
|
/** 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
|
|
282
|
+
async approveEnrollment(code: string, ownerID: string = LOCAL_USER): Promise<boolean> {
|
|
283
|
+
return (await this.decide(code, "approved", ownerID)) !== undefined
|
|
182
284
|
}
|
|
183
285
|
|
|
184
|
-
denyEnrollment(code: string): boolean {
|
|
185
|
-
return this.decide(code, "denied") !== undefined
|
|
286
|
+
async denyEnrollment(code: string): Promise<boolean> {
|
|
287
|
+
return (await this.decide(code, "denied")) !== undefined
|
|
186
288
|
}
|
|
187
289
|
|
|
188
|
-
/**
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
|
|
290
|
+
/**
|
|
291
|
+
* Approves or denies a pending enrollment; the machine name, or undefined when no such code is pending. The
|
|
292
|
+
* first decision wins (SET NX), on any pod.
|
|
293
|
+
*/
|
|
294
|
+
async decide(code: string, status: "approved" | "denied", ownerID: string = LOCAL_USER): Promise<string | undefined> {
|
|
295
|
+
const deviceCode = await this.kv.get(KEYS.enrollCode(normalizeUserCode(code)))
|
|
296
|
+
const enrollment = deviceCode === undefined ? undefined : await this.enrollment(deviceCode)
|
|
297
|
+
if (!enrollment || enrollment.status !== "pending") return undefined
|
|
298
|
+
const px = this.remaining(enrollment.expiresAt)
|
|
299
|
+
if (!(await this.kv.set(KEYS.enrollDecided(enrollment.deviceCode), status, { px, nx: true }))) return undefined
|
|
300
|
+
const decided: Enrollment = { ...enrollment, status, ...(status === "approved" ? { ownerID } : {}) }
|
|
301
|
+
await this.kv.set(KEYS.enroll(enrollment.deviceCode), JSON.stringify(decided), { px })
|
|
302
|
+
return enrollment.machineName
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** The enrollment of a device code, unless it expired. */
|
|
306
|
+
private async enrollment(deviceCode: string): Promise<Enrollment | undefined> {
|
|
307
|
+
const enrollment = parse<Enrollment>(await this.kv.get(KEYS.enroll(deviceCode)))
|
|
308
|
+
return enrollment && this.remaining(enrollment.expiresAt) > 0 ? enrollment : undefined
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Milliseconds a record expiring at `expiresAt` is still valid for (valid through expiresAt itself). */
|
|
312
|
+
private remaining(expiresAt: number): number {
|
|
313
|
+
return expiresAt - this.now() + 1
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---------- CLI login ----------
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Starts a CLI login for `clientName` (the terminal's machine name), asking for the default scopes plus
|
|
320
|
+
* `scopes`. Rate limited like enrollment. The device code is the poller's secret; the user code is what the
|
|
321
|
+
* approver confirms.
|
|
322
|
+
*/
|
|
323
|
+
async startCliLogin(clientName: unknown, scopes: unknown): Promise<CliLoginStart> {
|
|
324
|
+
const client = name(clientName, "client name")
|
|
325
|
+
const requested = [...new Set([...DEFAULT_CLI_LOGIN_SCOPES, ...scopeList(scopes, true)])]
|
|
326
|
+
if ((await this.counters.incr(KEYS.cliLoginRate, ENROLL_WINDOW_MS)) > ENROLL_MAX) throw new RateLimitError("Too many login requests; try again in a minute")
|
|
327
|
+
const created = this.now()
|
|
328
|
+
const expiresAt = created + CLI_LOGIN_TTL_MS
|
|
329
|
+
const px = CLI_LOGIN_TTL_MS + 1
|
|
330
|
+
const deviceCode = newToken()
|
|
331
|
+
let code = userCode()
|
|
332
|
+
while ((await this.kv.get(KEYS.enrollCode(code))) !== undefined || !(await this.kv.set(KEYS.cliLoginCode(code), deviceCode, { px, nx: true }))) code = userCode()
|
|
333
|
+
const login: CliLogin = { userCode: code, deviceCode, clientName: client, scopes: requested, created, expiresAt, status: "pending" }
|
|
334
|
+
await this.kv.set(KEYS.cliLogin(deviceCode), JSON.stringify(login), { px })
|
|
335
|
+
return { userCode: code, deviceCode, approveURL: `${this.publicURL}/approve?code=${code}`, intervalMs: ENROLL_INTERVAL_MS, expiresAt, scopes: requested }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The pending CLI login of a user code, for the approval page; undefined once decided or expired. */
|
|
339
|
+
async cliLogin(code: string): Promise<PendingCliLogin | undefined> {
|
|
340
|
+
const login = await this.cliLoginOf(code)
|
|
341
|
+
if (!login || login.status !== "pending") return undefined
|
|
342
|
+
const { userCode, clientName, scopes, created, expiresAt } = login
|
|
343
|
+
return { userCode, clientName, scopes: [...scopes], created, expiresAt }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Approves (as `ownerID`, with the `scopes` they chose: at least one known scope) or denies a pending CLI login:
|
|
348
|
+
* its client name, or undefined when no such code is pending. The first decision wins (SET NX), on any pod.
|
|
349
|
+
*/
|
|
350
|
+
async decideCliLogin(code: string, status: "approved" | "denied", ownerID: string, scopes?: unknown): Promise<string | undefined> {
|
|
351
|
+
const approved = status === "approved" ? scopeList(scopes, false) : undefined
|
|
352
|
+
if (approved && approved.length === 0) throw new ValidationError("Choose at least one scope")
|
|
353
|
+
const login = await this.cliLoginOf(code)
|
|
354
|
+
if (!login || login.status !== "pending") return undefined
|
|
355
|
+
const px = this.remaining(login.expiresAt)
|
|
356
|
+
if (!(await this.kv.set(KEYS.cliLoginDecided(login.deviceCode), status, { px, nx: true }))) return undefined
|
|
357
|
+
const decided: CliLogin = { ...login, status, ownerID, ...(approved ? { approved } : {}) }
|
|
358
|
+
await this.kv.set(KEYS.cliLogin(login.deviceCode), JSON.stringify(decided), { px })
|
|
359
|
+
return login.clientName
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** The decision on a CLI login, delivered once (GETDEL); afterwards the code reads as expired. */
|
|
363
|
+
async pollCliLogin(deviceCode: unknown): Promise<CliLoginPoll> {
|
|
364
|
+
const login = typeof deviceCode === "string" && deviceCode ? await this.pendingLogin(deviceCode) : undefined
|
|
365
|
+
if (!login) return { status: "expired" }
|
|
366
|
+
if (login.status === "pending") return { status: "pending" }
|
|
367
|
+
const taken = parse<CliLogin>(await this.kv.getdel(KEYS.cliLogin(login.deviceCode)))
|
|
368
|
+
if (!taken || taken.status === "pending") return { status: "expired" }
|
|
369
|
+
await this.kv.del(KEYS.cliLoginCode(taken.userCode))
|
|
370
|
+
if (taken.status === "denied" || !taken.ownerID || !taken.approved) return { status: "denied" }
|
|
371
|
+
return { status: "approved", ownerID: taken.ownerID, clientName: taken.clientName, scopes: [...taken.approved] }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private async cliLoginOf(code: string): Promise<CliLogin | undefined> {
|
|
375
|
+
const deviceCode = await this.kv.get(KEYS.cliLoginCode(normalizeUserCode(code)))
|
|
376
|
+
return deviceCode === undefined ? undefined : await this.pendingLogin(deviceCode)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** The CLI login of a device code, unless it expired. */
|
|
380
|
+
private async pendingLogin(deviceCode: string): Promise<CliLogin | undefined> {
|
|
381
|
+
const login = parse<CliLogin>(await this.kv.get(KEYS.cliLogin(deviceCode)))
|
|
382
|
+
return login && this.remaining(login.expiresAt) > 0 ? login : undefined
|
|
200
383
|
}
|
|
201
384
|
|
|
202
385
|
// ---------- device pairing ----------
|
|
203
386
|
|
|
204
387
|
/** A pairing code; the device it pairs belongs to `ownerID`. */
|
|
205
388
|
issuePairCode(ownerID: string = LOCAL_USER): { code: string; expiresAt: number } {
|
|
206
|
-
this.
|
|
389
|
+
const t = this.now()
|
|
390
|
+
for (const [code, expiresAt] of this.issuedPairCodes) if (expiresAt < t) this.issuedPairCodes.delete(code)
|
|
207
391
|
let code = String(randomInt(1_000_000)).padStart(6, "0")
|
|
208
|
-
while (this.
|
|
209
|
-
const
|
|
210
|
-
this.
|
|
211
|
-
return
|
|
392
|
+
while (this.issuedPairCodes.has(code)) code = String(randomInt(1_000_000)).padStart(6, "0")
|
|
393
|
+
const issued = this.issue("pair", code, ownerID, PAIR_TTL_MS)
|
|
394
|
+
this.issuedPairCodes.set(code, issued.expiresAt)
|
|
395
|
+
return issued
|
|
212
396
|
}
|
|
213
397
|
|
|
214
398
|
/** A pairing code for the first device (pairing mode only), or undefined once any device exists. */
|
|
@@ -220,52 +404,97 @@ export class Auth {
|
|
|
220
404
|
* Redeems a pairing code. Throws RateLimitError after too many recent failures. A code whose owner
|
|
221
405
|
* is gone or disabled counts as invalid.
|
|
222
406
|
*/
|
|
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)
|
|
407
|
+
async pair(code: unknown, deviceName: unknown): Promise<{ device: DeviceRecord; token: string } | undefined> {
|
|
408
|
+
if ((await this.counters.get(KEYS.pairFailures)) >= PAIR_FAILURES_MAX) throw new RateLimitError()
|
|
409
|
+
const grant = await this.redeem("pair", typeof code === "string" ? code.trim() : "")
|
|
228
410
|
if (!grant) {
|
|
229
|
-
this.
|
|
411
|
+
await this.counters.incr(KEYS.pairFailures, PAIR_FAILURES_WINDOW_MS)
|
|
230
412
|
return undefined
|
|
231
413
|
}
|
|
232
|
-
this.pairCodes.delete(key)
|
|
233
414
|
if (!this.active(grant.ownerID)) return undefined
|
|
234
|
-
return this.store.addDevice(name(deviceName, "device name", "Device"), grant.ownerID)
|
|
415
|
+
return await this.store.addDevice(name(deviceName, "device name", "Device"), grant.ownerID)
|
|
235
416
|
}
|
|
236
417
|
|
|
237
418
|
// ---------- login links ----------
|
|
238
419
|
|
|
239
420
|
/** A single-use login link code; it signs a browser in as `ownerID`. */
|
|
240
421
|
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 }
|
|
422
|
+
return this.issue("login", randomBytes(32).toString("base64url"), ownerID, LOGIN_TTL_MS)
|
|
245
423
|
}
|
|
246
424
|
|
|
247
425
|
/** 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
|
-
|
|
426
|
+
async redeemLogin(code: unknown): Promise<string | undefined> {
|
|
427
|
+
const grant = await this.redeem("login", code)
|
|
428
|
+
return grant && this.active(grant.ownerID) ? grant.ownerID : undefined
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async redeemLoginCode(code: unknown): Promise<boolean> {
|
|
432
|
+
return (await this.redeemLogin(code)) !== undefined
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ---------- app link codes ----------
|
|
436
|
+
|
|
437
|
+
/** A single-use app link code; the device it links belongs to `ownerID`. Never valid at /login or /api/pair. */
|
|
438
|
+
issueLinkCode(ownerID: string = LOCAL_USER): { code: string; expiresAt: number } {
|
|
439
|
+
return this.issue("link", randomBytes(32).toString("base64url"), ownerID, LINK_TTL_MS)
|
|
254
440
|
}
|
|
255
441
|
|
|
256
|
-
|
|
257
|
-
|
|
442
|
+
/**
|
|
443
|
+
* Redeems an app link code for a new device and its token, or undefined (unknown, used, expired, or owner
|
|
444
|
+
* gone or disabled). Throws ValidationError for a malformed public key (the code stays valid). The code is
|
|
445
|
+
* deleted before the device is created. No rate limiter: 256-bit codes cannot be guessed, and a shared
|
|
446
|
+
* limiter would let bad redeems lock out pairing.
|
|
447
|
+
*/
|
|
448
|
+
async redeemLink(code: unknown, deviceName: unknown, key?: unknown): Promise<{ device: DeviceRecord; token: string } | undefined> {
|
|
449
|
+
const label = deviceLabel(deviceName, "App")
|
|
450
|
+
const pk = publicKey(key)
|
|
451
|
+
const grant = await this.redeem("link", code)
|
|
452
|
+
if (!grant || !this.active(grant.ownerID)) return undefined
|
|
453
|
+
return await this.store.addDevice(label, grant.ownerID, pk ? { publicKey: pk } : {})
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ---------- pending codes ----------
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Issues a code at once and stores it in the background (callers are synchronous); a redeem in this process
|
|
460
|
+
* waits for the write, and another pod's redeem comes after a person typed or opened the code. Should the
|
|
461
|
+
* code already exist (another pod issued the same pairing code), both are dropped: a code never signs anyone
|
|
462
|
+
* in as someone else.
|
|
463
|
+
*/
|
|
464
|
+
private issue(kind: CodeKind, code: string, ownerID: string, ttlMs: number): { code: string; expiresAt: number } {
|
|
465
|
+
const expiresAt = this.now() + ttlMs
|
|
466
|
+
const key = KEYS.code(kind, code)
|
|
467
|
+
const grant: Grant = { expiresAt, ownerID }
|
|
468
|
+
this.track(
|
|
469
|
+
this.kv.set(key, JSON.stringify(grant), { px: ttlMs + 1, nx: true }).then(async (stored) => {
|
|
470
|
+
if (stored) return
|
|
471
|
+
await this.kv.del(key)
|
|
472
|
+
console.warn(`[hub] a new ${kind} code collided with a pending one; both were dropped`)
|
|
473
|
+
}),
|
|
474
|
+
)
|
|
475
|
+
return { code, expiresAt }
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Spends a code (GETDEL): its grant, or undefined (unknown, used, expired). */
|
|
479
|
+
private async redeem(kind: CodeKind, code: unknown): Promise<Grant | undefined> {
|
|
480
|
+
if (typeof code !== "string" || !code) return undefined
|
|
481
|
+
if (this.writes.size) await Promise.all([...this.writes])
|
|
482
|
+
const grant = parse<Grant>(await this.kv.getdel(KEYS.code(kind, code)))
|
|
483
|
+
return grant && this.remaining(grant.expiresAt) > 0 ? grant : undefined
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private track(write: Promise<unknown>): void {
|
|
487
|
+
const tracked: Promise<void> = write
|
|
488
|
+
.then(
|
|
489
|
+
() => {},
|
|
490
|
+
(cause: unknown) => console.error(`[hub] pending code not stored: ${cause instanceof Error ? cause.message : cause}`),
|
|
491
|
+
)
|
|
492
|
+
.finally(() => this.writes.delete(tracked))
|
|
493
|
+
this.writes.add(tracked)
|
|
258
494
|
}
|
|
259
495
|
|
|
260
496
|
private active(userID: string): boolean {
|
|
261
497
|
const user = this.store.user(userID)
|
|
262
498
|
return user !== undefined && !user.disabled
|
|
263
499
|
}
|
|
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
500
|
}
|