mslxdff 0.1.199 → 0.1.203
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/README.md +3 -3
- package/bin/mslxdff.js +1 -1
- package/package.json +38 -38
- package/plugins/README.md +14 -14
- package/plugins/prefer-model.mjs.example +14 -14
- package/src/anthropic/count-tokens.js +61 -61
- package/src/anthropic/request-body.js +126 -126
- package/src/anthropic/shared.js +18 -18
- package/src/anthropic/sse-aggregate.js +56 -56
- package/src/anthropic/translate.js +287 -287
- package/src/anthropic/web-search.js +361 -361
- package/src/api-kind.js +31 -31
- package/src/autostart.js +288 -288
- package/src/bench/probe.js +1 -1
- package/src/bench/runner.js +1 -1
- package/src/bench/via-probe.js +1 -1
- package/src/bench/via.js +144 -144
- package/src/bench/workbuddy-bench.js +1 -1
- package/src/chat/config.js +11 -11
- package/src/chat/cooling.js +81 -81
- package/src/chat/direct.js +58 -58
- package/src/chat/index.js +6 -6
- package/src/chat/spinner.js +38 -38
- package/src/chat/sse.js +69 -69
- package/src/chat/stats.js +247 -247
- package/src/chat/store.js +48 -48
- package/src/chat/tool-handlers.js +67 -67
- package/src/chat/tools.js +1 -1
- package/src/chat/upstream.js +59 -59
- package/src/chat-pipeline/auto-race.js +158 -158
- package/src/chat-pipeline/empty-turn.js +147 -147
- package/src/chat-pipeline/engine.js +17 -17
- package/src/chat-pipeline/serial-trial.js +283 -283
- package/src/chat-pipeline/terminal-verdict.js +147 -147
- package/src/chooser.js +54 -54
- package/src/claude-desktop/apply.js +101 -101
- package/src/claude-desktop/context1m.js +38 -38
- package/src/claude-desktop/doctor.js +146 -146
- package/src/claude-desktop/slots.js +152 -152
- package/src/cli/bootstrap.js +1 -1
- package/src/cli/commands/claude-desktop.js +284 -284
- package/src/cli/commands/daemon.js +231 -231
- package/src/cli/commands/model/list-live.js +22 -22
- package/src/cli/commands/model/list-providers.js +76 -76
- package/src/cli/commands/model/list-render.js +77 -77
- package/src/cli/commands/model/list-sort.js +52 -52
- package/src/cli/commands/model/list-tty.js +76 -76
- package/src/cli/commands/model/list.js +166 -166
- package/src/cli/commands/model/live-models.js +24 -24
- package/src/cli/commands/model/stats.js +43 -43
- package/src/cli/commands/model/status.js +47 -47
- package/src/cli/commands/model.js +51 -51
- package/src/cli/commands/provider/add.js +108 -108
- package/src/cli/commands/provider/allowlist.js +99 -99
- package/src/cli/commands/provider/cline-free.js +99 -99
- package/src/cli/commands/provider/cline-login.js +218 -218
- package/src/cli/commands/provider/cline-quota.js +65 -65
- package/src/cli/commands/provider/codearts-login.js +45 -45
- package/src/cli/commands/provider/config.js +119 -119
- package/src/cli/commands/provider/globalqwenwork-login.js +126 -126
- package/src/cli/commands/provider/keys-guard.js +50 -50
- package/src/cli/commands/provider/keys.js +161 -161
- package/src/cli/commands/provider/qoder-checkin.js +81 -81
- package/src/cli/commands/provider/qoder-login.js +102 -102
- package/src/cli/commands/provider/raccoon-login.js +180 -180
- package/src/cli/commands/provider/raccoon-password.js +162 -162
- package/src/cli/commands/provider/raccoon-quota.js +109 -109
- package/src/cli/commands/provider/traework-checkin.js +144 -144
- package/src/cli/commands/provider/traework-login.js +164 -164
- package/src/cli/commands/provider/workbuddy-login.js +153 -153
- package/src/cli/commands/provider/zcode-login.js +77 -77
- package/src/cli/commands/provider/zcode-quota.js +55 -55
- package/src/cli/commands/provider.js +1 -1
- package/src/cli/commands/stats.js +149 -149
- package/src/cli/commands/system.js +261 -261
- package/src/cli/commands/timezone.js +47 -47
- package/src/cli/commands/use-group.js +60 -60
- package/src/cli/commands/workbuddy.js +189 -189
- package/src/cli/format.js +141 -141
- package/src/cli/group-helpers.js +70 -70
- package/src/cli/help.js +80 -80
- package/src/cli/index.js +70 -70
- package/src/cli/interactive.js +121 -121
- package/src/cli/join-core.js +99 -99
- package/src/cli/join-wizard.js +175 -175
- package/src/cli/policy.js +136 -136
- package/src/cli/provider-row.js +126 -126
- package/src/cli/util.js +24 -24
- package/src/compat.js +53 -53
- package/src/events.js +38 -38
- package/src/free-lane.js +131 -131
- package/src/free-watcher.js +63 -63
- package/src/groups.js +262 -262
- package/src/logs.js +195 -195
- package/src/metrics.js +62 -62
- package/src/model-capabilities/context-lookup.js +65 -65
- package/src/model-capabilities/merge.js +142 -142
- package/src/model-trace.js +187 -187
- package/src/models.js +1 -1
- package/src/opencode-identity.js +72 -72
- package/src/peers.js +254 -251
- package/src/plugins.js +107 -107
- package/src/providers/base.js +164 -164
- package/src/providers/classify.js +22 -22
- package/src/providers/cline/allowlist-sync.js +93 -93
- package/src/providers/cline/free-catalog.js +71 -71
- package/src/providers/cline/headers.js +37 -37
- package/src/providers/cline/index.js +130 -130
- package/src/providers/cline/models.js +150 -150
- package/src/providers/cline/usage.js +274 -274
- package/src/providers/codearts/auth-pool.js +148 -148
- package/src/providers/codearts/concurrency.js +34 -34
- package/src/providers/codearts/const.js +65 -65
- package/src/providers/codearts/dpop.js +95 -95
- package/src/providers/codearts/index.js +175 -175
- package/src/providers/codearts/login.js +214 -214
- package/src/providers/codearts/models.js +182 -182
- package/src/providers/codearts/sign.js +96 -96
- package/src/providers/codearts/sse.js +204 -204
- package/src/providers/codearts/stream.js +209 -209
- package/src/providers/codearts/sts.js +124 -124
- package/src/providers/codearts.js +1 -1
- package/src/providers/dispatcher.js +146 -146
- package/src/providers/generic.js +156 -156
- package/src/providers/globalqwenwork/account-store.js +141 -141
- package/src/providers/globalqwenwork/constants.js +73 -73
- package/src/providers/globalqwenwork/crypto.js +2 -2
- package/src/providers/globalqwenwork/http.js +2 -2
- package/src/providers/globalqwenwork/index.js +331 -331
- package/src/providers/globalqwenwork/payload.js +147 -147
- package/src/providers/globalqwenwork/rsa.js +2 -2
- package/src/providers/globalqwenwork/sse.js +2 -2
- package/src/providers/globalqwenwork/stream.js +2 -2
- package/src/providers/globalqwenwork.js +1 -1
- package/src/providers/globalworkbuddy/account-store.js +198 -198
- package/src/providers/globalworkbuddy/auth.js +97 -97
- package/src/providers/globalworkbuddy/catalog.js +138 -138
- package/src/providers/globalworkbuddy/constants.js +73 -73
- package/src/providers/globalworkbuddy/device-auth.js +120 -120
- package/src/providers/globalworkbuddy/errors.js +98 -98
- package/src/providers/globalworkbuddy/headers.js +89 -89
- package/src/providers/globalworkbuddy/models.js +96 -96
- package/src/providers/globalworkbuddy/payload.js +80 -80
- package/src/providers/index.js +5 -5
- package/src/providers/model-id.js +172 -172
- package/src/providers/opencode.js +19 -19
- package/src/providers/openrouter.js +31 -31
- package/src/providers/qoder/account-store.js +106 -106
- package/src/providers/qoder/aggregate.js +60 -60
- package/src/providers/qoder/baseprompt.json +591 -591
- package/src/providers/qoder/checkin.js +160 -160
- package/src/providers/qoder/constants.js +44 -44
- package/src/providers/qoder/encode.js +41 -41
- package/src/providers/qoder/fingerprint.js +38 -38
- package/src/providers/qoder/models.js +77 -77
- package/src/providers/qoder/oauth.js +84 -84
- package/src/providers/qoder/payload.js +162 -162
- package/src/providers/qoder/request.js +27 -27
- package/src/providers/qoder/session.js +134 -134
- package/src/providers/qoder/sticky.js +38 -38
- package/src/providers/qoder/stream.js +116 -116
- package/src/providers/qoder.js +1 -1
- package/src/providers/raccoon/account-store.js +216 -216
- package/src/providers/raccoon/auth.js +67 -67
- package/src/providers/raccoon/chat.js +228 -228
- package/src/providers/raccoon/const.js +180 -165
- package/src/providers/raccoon/credits.js +185 -175
- package/src/providers/raccoon/crypto.js +43 -0
- package/src/providers/raccoon/envelope.js +31 -31
- package/src/providers/raccoon/headers.js +45 -45
- package/src/providers/raccoon/index.js +216 -216
- package/src/providers/raccoon/login.js +279 -279
- package/src/providers/raccoon/models.js +160 -160
- package/src/providers/raccoon/password-login.js +102 -87
- package/src/providers/raccoon/qr-codewords.js +115 -115
- package/src/providers/raccoon/qr.js +236 -236
- package/src/providers/raccoon/sse.js +135 -135
- package/src/providers/responses-channel.js +120 -120
- package/src/providers/share-keys.js +53 -53
- package/src/providers/traework/account-store.js +234 -234
- package/src/providers/traework/chat.js +217 -217
- package/src/providers/traework/checkin.js +77 -77
- package/src/providers/traework/constants.js +41 -41
- package/src/providers/traework/errors.js +52 -52
- package/src/providers/traework/headers.js +79 -79
- package/src/providers/traework/index.js +132 -132
- package/src/providers/traework/market-identity.js +116 -116
- package/src/providers/traework/models.js +98 -98
- package/src/providers/traework/payload.js +86 -86
- package/src/providers/traework/sse.js +184 -184
- package/src/providers/traework/token.js +52 -52
- package/src/providers/traework.js +2 -2
- package/src/providers/workbuddy/account-store.js +186 -186
- package/src/providers/workbuddy/auth.js +158 -158
- package/src/providers/workbuddy/balance.js +86 -86
- package/src/providers/workbuddy/cat-travel.js +125 -125
- package/src/providers/workbuddy/checkin.js +75 -75
- package/src/providers/workbuddy/desktop-info.js +78 -78
- package/src/providers/workbuddy/growth-api.js +67 -67
- package/src/providers/workbuddy/growth-plans.js +63 -63
- package/src/providers/workbuddy/growth.js +181 -181
- package/src/providers/workbuddy/index.js +217 -217
- package/src/providers/workbuddy/models.js +126 -126
- package/src/providers/workbuddy/payload.js +141 -141
- package/src/providers/workbuddy/rotation-log.js +83 -83
- package/src/providers/workbuddy/sanitize-tools.js +61 -61
- package/src/providers/workbuddy/sdk-chat.js +26 -26
- package/src/providers/workbuddy-balance.js +78 -78
- package/src/providers/zcode/account-store.js +129 -129
- package/src/providers/zcode/auth.js +28 -28
- package/src/providers/zcode/chat.js +179 -179
- package/src/providers/zcode/const.js +54 -54
- package/src/providers/zcode/context-shape.js +162 -162
- package/src/providers/zcode/headers.js +93 -93
- package/src/providers/zcode/index.js +124 -124
- package/src/providers/zcode/models.js +65 -65
- package/src/providers/zcode/oauth.js +120 -120
- package/src/providers/zcode/quota.js +176 -176
- package/src/providers/zcode/sse.js +196 -196
- package/src/readline-compat.js +25 -25
- package/src/responses/translate.js +311 -311
- package/src/routes/chat/broadband-handler.js +72 -72
- package/src/routes/chat/empty-turn-report.js +31 -31
- package/src/routes/chat/exhausted-handler.js +87 -87
- package/src/routes/chat/hedge-handler.js +130 -130
- package/src/routes/chat/index.js +1 -1
- package/src/routes/chat/local-handler.js +63 -63
- package/src/routes/chat/peer-handler.js +81 -81
- package/src/routes/chat.js +3 -3
- package/src/routes/fallback.js +88 -88
- package/src/routes/groups-relay.js +214 -214
- package/src/routes/groups.js +122 -122
- package/src/routes/helpers.js +83 -83
- package/src/routes/index.js +147 -147
- package/src/routes/messages-route.js +365 -365
- package/src/routes/peers.js +23 -1
- package/src/routes/relay-queue.js +181 -181
- package/src/routes/relay.js +60 -60
- package/src/routes/responses-route.js +213 -213
- package/src/routes/stream-body.js +84 -84
- package/src/routes/stream-hold.js +337 -337
- package/src/routes/stream-scan.js +366 -366
- package/src/routes.js +3 -3
- package/src/runtime/auto-update.js +113 -113
- package/src/runtime/bootstrap.js +8 -0
- package/src/runtime/broadband-stream.js +79 -79
- package/src/runtime/broadband.js +103 -103
- package/src/runtime/group-sync.js +81 -81
- package/src/runtime/keep-warm.js +120 -0
- package/src/runtime/lifecycle-forensics.js +116 -116
- package/src/runtime/lifecycle-log.js +87 -87
- package/src/runtime/log-stamp.js +73 -73
- package/src/runtime/proc-info.js +137 -137
- package/src/runtime/qoder-checkin.js +93 -93
- package/src/runtime/raccoon-checkin.js +126 -126
- package/src/runtime/server-lifecycle.js +189 -189
- package/src/runtime/traework-checkin.js +118 -118
- package/src/runtime/workbuddy-checkin.js +120 -120
- package/src/runtime/workbuddy-growth.js +133 -133
- package/src/server.js +7 -0
- package/src/state/facade.js +58 -58
- package/src/state/memory.js +162 -162
- package/src/state/merge.js +25 -25
- package/src/state/migrations/cline-unify.js +94 -94
- package/src/state/migrations.js +33 -33
- package/src/state/persist.js +42 -42
- package/src/state/provider-config.js +142 -142
- package/src/state/schemas/group.js +31 -31
- package/src/state/schemas/model.js +94 -94
- package/src/state/schemas/peer.js +31 -31
- package/src/state/schemas/port.js +10 -10
- package/src/state/schemas/provider.js +202 -202
- package/src/state/schemas/timezone.js +70 -70
- package/src/state/schemas/token.js +48 -48
- package/src/state/schemas/use-group.js +102 -102
- package/src/state/store.js +176 -176
- package/src/state.js +1 -1
- package/src/sync-claude-desktop.js +234 -234
- package/src/sync-claude.js +152 -152
- package/src/sync-codex.js +116 -116
- package/src/sync-workbuddy.js +158 -158
- package/src/talk-full-redact.js +129 -129
- package/src/talk-full.js +131 -131
- package/src/talk-log.js +257 -257
- package/src/text-tokens.js +52 -52
- package/src/thinking-effort.js +67 -67
- package/src/time.js +106 -106
- package/src/timeline.js +26 -26
- package/src/transport/index.js +1 -1
- package/src/upstream-engine/index.js +74 -72
- package/src/upstream-engine/mode.js +15 -15
- package/src/upstream-engine/sdk/attempt.js +221 -221
- package/src/upstream-engine/sdk/chat.js +28 -28
- package/src/upstream-engine/sdk/convert.js +192 -192
- package/src/upstream-engine/sdk/diagnose.js +62 -62
- package/src/upstream-engine/sdk/dispatch.js +55 -55
- package/src/upstream-engine/sdk/responses.js +176 -176
- package/src/upstream-engine/sdk/sse.js +166 -166
- package/src/upstream-responses.js +305 -305
- package/src/upstream.js +53 -1
- package/src/usage/record.js +106 -106
- package/src/usage/report.js +195 -195
- package/src/v6pool/claim.js +157 -157
- package/src/v6pool/config.js +65 -65
- package/src/v6pool/detect.js +130 -130
- package/src/v6pool/index.js +152 -152
- package/src/v6pool/prefix.js +140 -140
- package/src/v6pool/probe.js +40 -40
- package/src/v6pool/report.js +104 -104
- package/src/v6pool/rotate.js +263 -239
- package/src/warp-pool/config.js +74 -74
- package/src/warp-pool/health.js +106 -106
- package/src/warp-pool/identity.js +141 -141
- package/src/warp-pool/index.js +358 -358
- package/src/warp-pool/keys.js +29 -29
- package/src/warp-pool/nodes.js +95 -95
- package/src/warp-pool/ports.js +160 -160
- package/src/warp-pool/register.js +113 -113
- package/src/warp-pool/report.js +232 -232
- package/src/warp-pool/revive.js +168 -168
- package/src/warp-pool/rotate.js +214 -214
- package/src/warp-pool/strategy.js +43 -43
- package/src/warp-pool/verdict.js +69 -69
- package/src/warp-pool/wireproxy.js +162 -162
- package/docs/cli_help_mini.md +0 -146
- package/src/providers/AGENTS.md +0 -49
- package/src/routes/AGENTS.md +0 -37
package/src/groups.js
CHANGED
|
@@ -1,262 +1,262 @@
|
|
|
1
|
-
import { timingSafeEqual, createHash } from "node:crypto";
|
|
2
|
-
import { compatFetch } from "./compat.js";
|
|
3
|
-
import { loadGroups, saveGroups, loadBans, saveBans } from "./state.js";
|
|
4
|
-
import { normalizePeerUrl } from "./peers.js";
|
|
5
|
-
|
|
6
|
-
export const DEFAULT_GROUP_SYNC_MS = 60_000;
|
|
7
|
-
export const DEFAULT_BAN_WINDOW_MS = 48 * 60 * 60 * 1000;
|
|
8
|
-
export const DEFAULT_BAN_THRESHOLD = 5;
|
|
9
|
-
const SYNC_TIMEOUT_MS = 15_000;
|
|
10
|
-
|
|
11
|
-
function digestKey(key) {
|
|
12
|
-
return createHash("sha256").update(String(key || "")).digest();
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function verifyGroupKey(provided, expected) {
|
|
16
|
-
if (typeof provided !== "string" || typeof expected !== "string") return false;
|
|
17
|
-
return timingSafeEqual(digestKey(provided), digestKey(expected));
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// Leader side: create a group. The group name IS the password — anyone who
|
|
21
|
-
// knows the name can join, so pick something unguessable.
|
|
22
|
-
export function createGroup(name, { file, key = name } = {}) {
|
|
23
|
-
if (!name) throw new Error("group name is required");
|
|
24
|
-
const groups = loadGroups(file ? { file } : {});
|
|
25
|
-
if (groups[name]) return { name, key: groups[name].key, created: false };
|
|
26
|
-
groups[name] = { key, members: {} };
|
|
27
|
-
saveGroups(groups, file ? { file } : {});
|
|
28
|
-
return { name, key, created: true };
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Shared writer: register a member, de-duplicated by url (same ip:port
|
|
32
|
-
// re-registering under a different name updates the existing entry instead of
|
|
33
|
-
// creating a duplicate).
|
|
34
|
-
function writeMember(group, memberName, url, token, extra = {}) {
|
|
35
|
-
const members = group.members || (group.members = {});
|
|
36
|
-
const byUrl = Object.entries(members).find(([, m]) => m.url === url);
|
|
37
|
-
const entry = { url, token: token || "", ...extra };
|
|
38
|
-
// normalize kind default
|
|
39
|
-
if (!entry.kind) entry.kind = "static";
|
|
40
|
-
if (byUrl) {
|
|
41
|
-
const prev = members[byUrl[0]] || {};
|
|
42
|
-
// merge: keep url/token from new, but preserve fields if not supplied
|
|
43
|
-
const merged = { ...prev, ...entry };
|
|
44
|
-
// ensure url/token from latest call win
|
|
45
|
-
merged.url = url;
|
|
46
|
-
merged.token = token || prev.token || "";
|
|
47
|
-
members[byUrl[0]] = merged;
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
const id = memberName || url;
|
|
51
|
-
// if id already exists with different url, update that entry too (fallback)
|
|
52
|
-
if (members[id] && members[id].url !== url) {
|
|
53
|
-
// treat as new url, keep old entry but also update
|
|
54
|
-
}
|
|
55
|
-
members[id] = { ...entry, url, token: token || "" };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Leader side: add a member after verifying the join key.
|
|
59
|
-
export function addGroupMember(name, { key, memberName, url, token, kind, publicIp, lastSeen, status, file } = {}) {
|
|
60
|
-
const groups = loadGroups(file ? { file } : {});
|
|
61
|
-
const group = groups[name];
|
|
62
|
-
if (!group) throw new Error(`group "${name}" not found on this node`);
|
|
63
|
-
if (!verifyGroupKey(key, group.key)) throw new Error("invalid group key");
|
|
64
|
-
if (!url) throw new Error("member url is required");
|
|
65
|
-
const extra = {};
|
|
66
|
-
if (kind) extra.kind = kind;
|
|
67
|
-
if (publicIp) extra.publicIp = publicIp;
|
|
68
|
-
if (lastSeen) extra.lastSeen = lastSeen;
|
|
69
|
-
if (status) extra.status = status;
|
|
70
|
-
writeMember(group, memberName, url, token, extra);
|
|
71
|
-
saveGroups(groups, file ? { file } : {});
|
|
72
|
-
return group.members;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Leader side: upsert a member without key verification (used by the sync
|
|
76
|
-
// path after the member's bearer token has already been validated).
|
|
77
|
-
export function upsertMember(name, { memberName, url, token, kind, publicIp, lastSeen, status, file } = {}) {
|
|
78
|
-
const groups = loadGroups(file ? { file } : {});
|
|
79
|
-
const group = groups[name];
|
|
80
|
-
if (!group) return null;
|
|
81
|
-
if (!url) throw new Error("member url is required");
|
|
82
|
-
const extra = {};
|
|
83
|
-
if (kind) extra.kind = kind;
|
|
84
|
-
if (publicIp) extra.publicIp = publicIp;
|
|
85
|
-
if (lastSeen) extra.lastSeen = lastSeen;
|
|
86
|
-
if (status) extra.status = status;
|
|
87
|
-
writeMember(group, memberName, url, token, extra);
|
|
88
|
-
saveGroups(groups, file ? { file } : {});
|
|
89
|
-
return group.members;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Leader side: confirm a bearer token matches a registered member, then return
|
|
93
|
-
// the member list. Used by the sync path (registered members re-registering).
|
|
94
|
-
export function membersForToken(name, providedToken, { file } = {}) {
|
|
95
|
-
if (typeof providedToken !== "string" || !providedToken) return null;
|
|
96
|
-
const groups = loadGroups(file ? { file } : {});
|
|
97
|
-
const group = groups[name];
|
|
98
|
-
if (!group) return null;
|
|
99
|
-
const hit = Object.entries(group.members || {}).find(([, m]) =>
|
|
100
|
-
typeof m.token === "string" && m.token.length > 0 &&
|
|
101
|
-
timingSafeEqual(digestKey(m.token), digestKey(providedToken)));
|
|
102
|
-
return hit ? { member: hit[1], members: group.members } : null;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Leader side: list members (requires the join key).
|
|
106
|
-
export function listGroupMembers(name, { key, file } = {}) {
|
|
107
|
-
const groups = loadGroups(file ? { file } : {});
|
|
108
|
-
const group = groups[name];
|
|
109
|
-
if (!group) throw new Error(`group "${name}" not found on this node`);
|
|
110
|
-
if (!verifyGroupKey(key, group.key)) throw new Error("invalid group key");
|
|
111
|
-
return group.members;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export function listGroups({ file } = {}) {
|
|
115
|
-
return loadGroups(file ? { file } : {});
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Leader side: remove a member by exact url (a member leaving the group).
|
|
119
|
-
export function removeGroupMember(name, { url, file } = {}) {
|
|
120
|
-
const groups = loadGroups(file ? { file } : {});
|
|
121
|
-
const group = groups[name];
|
|
122
|
-
if (!group) return null;
|
|
123
|
-
if (!url) throw new Error("member url is required");
|
|
124
|
-
for (const [id, m] of Object.entries(group.members || {})) {
|
|
125
|
-
if (m.url === String(url).replace(/\/+$/, "")) {
|
|
126
|
-
delete group.members[id];
|
|
127
|
-
saveGroups(groups, file ? { file } : {});
|
|
128
|
-
return { removed: { id, url: m.url } };
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
return null;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Leader side: disband a group entirely — deletes the group and all its members.
|
|
135
|
-
// Used by -leavegroup on the leader node. Also returns the member list so the
|
|
136
|
-
// caller can notify members before (or after) the group is gone.
|
|
137
|
-
export function deleteGroup(name, { file } = {}) {
|
|
138
|
-
const groups = loadGroups(file ? { file } : {});
|
|
139
|
-
const group = groups[name];
|
|
140
|
-
if (!group) return null;
|
|
141
|
-
const members = group.members || {};
|
|
142
|
-
delete groups[name];
|
|
143
|
-
saveGroups(groups, file ? { file } : {});
|
|
144
|
-
return { members };
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export function createGroupsService({ file } = {}) {
|
|
148
|
-
const opts = (o = {}) => (file ? { ...o, file } : o);
|
|
149
|
-
return {
|
|
150
|
-
create: (name, o = {}) => createGroup(name, opts(o)),
|
|
151
|
-
addMember: (name, o = {}) => addGroupMember(name, opts(o)),
|
|
152
|
-
upsertMember: (name, o = {}) => upsertMember(name, opts(o)),
|
|
153
|
-
listMembers: (name, o = {}) => listGroupMembers(name, opts(o)),
|
|
154
|
-
membersForToken: (name, token) => membersForToken(name, token, opts()),
|
|
155
|
-
removeMember: (name, o = {}) => removeGroupMember(name, opts(o)),
|
|
156
|
-
delete: (name, o = {}) => deleteGroup(name, opts(o)),
|
|
157
|
-
list: () => listGroups(opts()),
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Re-register with the leader (join is idempotent) and return the fresh member list.
|
|
162
|
-
// No key is needed once registered: the leader verifies our bearer token.
|
|
163
|
-
export async function refreshGroupMembers(name, { leaderUrl, memberName, url, token, kind, fetchImpl = compatFetch } = {}) {
|
|
164
|
-
const controller = new AbortController();
|
|
165
|
-
const timer = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS);
|
|
166
|
-
try {
|
|
167
|
-
const body = { name, memberName, url, token };
|
|
168
|
-
if (kind) body.kind = kind;
|
|
169
|
-
const res = await fetchImpl(`${leaderUrl}/v1/groups/join`, {
|
|
170
|
-
method: "POST",
|
|
171
|
-
headers: {
|
|
172
|
-
"Content-Type": "application/json",
|
|
173
|
-
"Authorization": `Bearer ${token}`,
|
|
174
|
-
},
|
|
175
|
-
body: JSON.stringify(body),
|
|
176
|
-
signal: controller.signal,
|
|
177
|
-
});
|
|
178
|
-
if (!res.ok) {
|
|
179
|
-
const text = await res.text().catch(() => "");
|
|
180
|
-
throw new Error(`group sync failed (HTTP ${res.status}): ${text}`);
|
|
181
|
-
}
|
|
182
|
-
const data = await res.json();
|
|
183
|
-
return data.members || {};
|
|
184
|
-
} finally {
|
|
185
|
-
clearTimeout(timer);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Merge a member map into the local peer list, replacing the previous snapshot
|
|
190
|
-
// of this group and skipping ourselves. Used by both leaders (local groups) and
|
|
191
|
-
// members (leader-pulled members).
|
|
192
|
-
// broadband members (relay://) are NOT added as direct peers — they are
|
|
193
|
-
// reached via the leader relay, so we skip them here.
|
|
194
|
-
export function syncPeersFromMembers({ peers, members, myUrl, group, skipIds = [] }) {
|
|
195
|
-
const self = normalizePeerUrl(myUrl);
|
|
196
|
-
const skip = new Set(skipIds);
|
|
197
|
-
const removed = peers.removeByGroup(group);
|
|
198
|
-
let added = 0;
|
|
199
|
-
for (const [id, m] of Object.entries(members || {})) {
|
|
200
|
-
if (skip.has(id)) continue;
|
|
201
|
-
const kind = m?.kind || "static";
|
|
202
|
-
if (kind === "broadband" || String(m?.url || "").startsWith("relay://")) continue;
|
|
203
|
-
const url = normalizePeerUrl(m?.url);
|
|
204
|
-
if (!url || url === self) continue;
|
|
205
|
-
if (peers.add({ name: id, url, token: m?.token || "", group })) added++;
|
|
206
|
-
}
|
|
207
|
-
return { removed, added, total: Object.keys(members || {}).length };
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// ---- join-failure bans (per source IP) ----
|
|
211
|
-
|
|
212
|
-
export function createBansService({ file, now = () => Date.now(), windowMs = DEFAULT_BAN_WINDOW_MS, threshold = DEFAULT_BAN_THRESHOLD } = {}) {
|
|
213
|
-
let bans = loadBans(file ? { file } : {});
|
|
214
|
-
|
|
215
|
-
function prune() {
|
|
216
|
-
let changed = false;
|
|
217
|
-
for (const [ip, b] of Object.entries(bans)) {
|
|
218
|
-
if (b.bannedAt !== undefined && now() - b.bannedAt >= windowMs) {
|
|
219
|
-
delete bans[ip];
|
|
220
|
-
changed = true;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
if (changed) saveBans(bans, file ? { file } : {});
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function isBanned(ip) {
|
|
227
|
-
if (!ip) return false;
|
|
228
|
-
prune();
|
|
229
|
-
const b = bans[ip];
|
|
230
|
-
if (b?.bannedAt === undefined) return false;
|
|
231
|
-
return { bannedAt: b.bannedAt, until: b.bannedAt + windowMs };
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function recordFailure(ip) {
|
|
235
|
-
if (!ip) return null;
|
|
236
|
-
prune();
|
|
237
|
-
const b = (bans[ip] = bans[ip] || { fails: 0 });
|
|
238
|
-
b.fails = (b.fails || 0) + 1;
|
|
239
|
-
if (b.fails >= threshold) {
|
|
240
|
-
b.bannedAt = now();
|
|
241
|
-
b.fails = 0;
|
|
242
|
-
}
|
|
243
|
-
saveBans(bans, file ? { file } : {});
|
|
244
|
-
return b.bannedAt !== undefined ? b : null;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function clear(ip) {
|
|
248
|
-
if (ip) {
|
|
249
|
-
delete bans[ip];
|
|
250
|
-
} else {
|
|
251
|
-
bans = {};
|
|
252
|
-
}
|
|
253
|
-
saveBans(bans, file ? { file } : {});
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function list() {
|
|
257
|
-
prune();
|
|
258
|
-
return { ...bans };
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
return { isBanned, recordFailure, clear, list, windowMs, threshold };
|
|
262
|
-
}
|
|
1
|
+
import { timingSafeEqual, createHash } from "node:crypto";
|
|
2
|
+
import { compatFetch } from "./compat.js";
|
|
3
|
+
import { loadGroups, saveGroups, loadBans, saveBans } from "./state.js";
|
|
4
|
+
import { normalizePeerUrl } from "./peers.js";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_GROUP_SYNC_MS = 60_000;
|
|
7
|
+
export const DEFAULT_BAN_WINDOW_MS = 48 * 60 * 60 * 1000;
|
|
8
|
+
export const DEFAULT_BAN_THRESHOLD = 5;
|
|
9
|
+
const SYNC_TIMEOUT_MS = 15_000;
|
|
10
|
+
|
|
11
|
+
function digestKey(key) {
|
|
12
|
+
return createHash("sha256").update(String(key || "")).digest();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function verifyGroupKey(provided, expected) {
|
|
16
|
+
if (typeof provided !== "string" || typeof expected !== "string") return false;
|
|
17
|
+
return timingSafeEqual(digestKey(provided), digestKey(expected));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Leader side: create a group. The group name IS the password — anyone who
|
|
21
|
+
// knows the name can join, so pick something unguessable.
|
|
22
|
+
export function createGroup(name, { file, key = name } = {}) {
|
|
23
|
+
if (!name) throw new Error("group name is required");
|
|
24
|
+
const groups = loadGroups(file ? { file } : {});
|
|
25
|
+
if (groups[name]) return { name, key: groups[name].key, created: false };
|
|
26
|
+
groups[name] = { key, members: {} };
|
|
27
|
+
saveGroups(groups, file ? { file } : {});
|
|
28
|
+
return { name, key, created: true };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Shared writer: register a member, de-duplicated by url (same ip:port
|
|
32
|
+
// re-registering under a different name updates the existing entry instead of
|
|
33
|
+
// creating a duplicate).
|
|
34
|
+
function writeMember(group, memberName, url, token, extra = {}) {
|
|
35
|
+
const members = group.members || (group.members = {});
|
|
36
|
+
const byUrl = Object.entries(members).find(([, m]) => m.url === url);
|
|
37
|
+
const entry = { url, token: token || "", ...extra };
|
|
38
|
+
// normalize kind default
|
|
39
|
+
if (!entry.kind) entry.kind = "static";
|
|
40
|
+
if (byUrl) {
|
|
41
|
+
const prev = members[byUrl[0]] || {};
|
|
42
|
+
// merge: keep url/token from new, but preserve fields if not supplied
|
|
43
|
+
const merged = { ...prev, ...entry };
|
|
44
|
+
// ensure url/token from latest call win
|
|
45
|
+
merged.url = url;
|
|
46
|
+
merged.token = token || prev.token || "";
|
|
47
|
+
members[byUrl[0]] = merged;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const id = memberName || url;
|
|
51
|
+
// if id already exists with different url, update that entry too (fallback)
|
|
52
|
+
if (members[id] && members[id].url !== url) {
|
|
53
|
+
// treat as new url, keep old entry but also update
|
|
54
|
+
}
|
|
55
|
+
members[id] = { ...entry, url, token: token || "" };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Leader side: add a member after verifying the join key.
|
|
59
|
+
export function addGroupMember(name, { key, memberName, url, token, kind, publicIp, lastSeen, status, file } = {}) {
|
|
60
|
+
const groups = loadGroups(file ? { file } : {});
|
|
61
|
+
const group = groups[name];
|
|
62
|
+
if (!group) throw new Error(`group "${name}" not found on this node`);
|
|
63
|
+
if (!verifyGroupKey(key, group.key)) throw new Error("invalid group key");
|
|
64
|
+
if (!url) throw new Error("member url is required");
|
|
65
|
+
const extra = {};
|
|
66
|
+
if (kind) extra.kind = kind;
|
|
67
|
+
if (publicIp) extra.publicIp = publicIp;
|
|
68
|
+
if (lastSeen) extra.lastSeen = lastSeen;
|
|
69
|
+
if (status) extra.status = status;
|
|
70
|
+
writeMember(group, memberName, url, token, extra);
|
|
71
|
+
saveGroups(groups, file ? { file } : {});
|
|
72
|
+
return group.members;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Leader side: upsert a member without key verification (used by the sync
|
|
76
|
+
// path after the member's bearer token has already been validated).
|
|
77
|
+
export function upsertMember(name, { memberName, url, token, kind, publicIp, lastSeen, status, file } = {}) {
|
|
78
|
+
const groups = loadGroups(file ? { file } : {});
|
|
79
|
+
const group = groups[name];
|
|
80
|
+
if (!group) return null;
|
|
81
|
+
if (!url) throw new Error("member url is required");
|
|
82
|
+
const extra = {};
|
|
83
|
+
if (kind) extra.kind = kind;
|
|
84
|
+
if (publicIp) extra.publicIp = publicIp;
|
|
85
|
+
if (lastSeen) extra.lastSeen = lastSeen;
|
|
86
|
+
if (status) extra.status = status;
|
|
87
|
+
writeMember(group, memberName, url, token, extra);
|
|
88
|
+
saveGroups(groups, file ? { file } : {});
|
|
89
|
+
return group.members;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Leader side: confirm a bearer token matches a registered member, then return
|
|
93
|
+
// the member list. Used by the sync path (registered members re-registering).
|
|
94
|
+
export function membersForToken(name, providedToken, { file } = {}) {
|
|
95
|
+
if (typeof providedToken !== "string" || !providedToken) return null;
|
|
96
|
+
const groups = loadGroups(file ? { file } : {});
|
|
97
|
+
const group = groups[name];
|
|
98
|
+
if (!group) return null;
|
|
99
|
+
const hit = Object.entries(group.members || {}).find(([, m]) =>
|
|
100
|
+
typeof m.token === "string" && m.token.length > 0 &&
|
|
101
|
+
timingSafeEqual(digestKey(m.token), digestKey(providedToken)));
|
|
102
|
+
return hit ? { member: hit[1], members: group.members } : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Leader side: list members (requires the join key).
|
|
106
|
+
export function listGroupMembers(name, { key, file } = {}) {
|
|
107
|
+
const groups = loadGroups(file ? { file } : {});
|
|
108
|
+
const group = groups[name];
|
|
109
|
+
if (!group) throw new Error(`group "${name}" not found on this node`);
|
|
110
|
+
if (!verifyGroupKey(key, group.key)) throw new Error("invalid group key");
|
|
111
|
+
return group.members;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function listGroups({ file } = {}) {
|
|
115
|
+
return loadGroups(file ? { file } : {});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Leader side: remove a member by exact url (a member leaving the group).
|
|
119
|
+
export function removeGroupMember(name, { url, file } = {}) {
|
|
120
|
+
const groups = loadGroups(file ? { file } : {});
|
|
121
|
+
const group = groups[name];
|
|
122
|
+
if (!group) return null;
|
|
123
|
+
if (!url) throw new Error("member url is required");
|
|
124
|
+
for (const [id, m] of Object.entries(group.members || {})) {
|
|
125
|
+
if (m.url === String(url).replace(/\/+$/, "")) {
|
|
126
|
+
delete group.members[id];
|
|
127
|
+
saveGroups(groups, file ? { file } : {});
|
|
128
|
+
return { removed: { id, url: m.url } };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Leader side: disband a group entirely — deletes the group and all its members.
|
|
135
|
+
// Used by -leavegroup on the leader node. Also returns the member list so the
|
|
136
|
+
// caller can notify members before (or after) the group is gone.
|
|
137
|
+
export function deleteGroup(name, { file } = {}) {
|
|
138
|
+
const groups = loadGroups(file ? { file } : {});
|
|
139
|
+
const group = groups[name];
|
|
140
|
+
if (!group) return null;
|
|
141
|
+
const members = group.members || {};
|
|
142
|
+
delete groups[name];
|
|
143
|
+
saveGroups(groups, file ? { file } : {});
|
|
144
|
+
return { members };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function createGroupsService({ file } = {}) {
|
|
148
|
+
const opts = (o = {}) => (file ? { ...o, file } : o);
|
|
149
|
+
return {
|
|
150
|
+
create: (name, o = {}) => createGroup(name, opts(o)),
|
|
151
|
+
addMember: (name, o = {}) => addGroupMember(name, opts(o)),
|
|
152
|
+
upsertMember: (name, o = {}) => upsertMember(name, opts(o)),
|
|
153
|
+
listMembers: (name, o = {}) => listGroupMembers(name, opts(o)),
|
|
154
|
+
membersForToken: (name, token) => membersForToken(name, token, opts()),
|
|
155
|
+
removeMember: (name, o = {}) => removeGroupMember(name, opts(o)),
|
|
156
|
+
delete: (name, o = {}) => deleteGroup(name, opts(o)),
|
|
157
|
+
list: () => listGroups(opts()),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Re-register with the leader (join is idempotent) and return the fresh member list.
|
|
162
|
+
// No key is needed once registered: the leader verifies our bearer token.
|
|
163
|
+
export async function refreshGroupMembers(name, { leaderUrl, memberName, url, token, kind, fetchImpl = compatFetch } = {}) {
|
|
164
|
+
const controller = new AbortController();
|
|
165
|
+
const timer = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS);
|
|
166
|
+
try {
|
|
167
|
+
const body = { name, memberName, url, token };
|
|
168
|
+
if (kind) body.kind = kind;
|
|
169
|
+
const res = await fetchImpl(`${leaderUrl}/v1/groups/join`, {
|
|
170
|
+
method: "POST",
|
|
171
|
+
headers: {
|
|
172
|
+
"Content-Type": "application/json",
|
|
173
|
+
"Authorization": `Bearer ${token}`,
|
|
174
|
+
},
|
|
175
|
+
body: JSON.stringify(body),
|
|
176
|
+
signal: controller.signal,
|
|
177
|
+
});
|
|
178
|
+
if (!res.ok) {
|
|
179
|
+
const text = await res.text().catch(() => "");
|
|
180
|
+
throw new Error(`group sync failed (HTTP ${res.status}): ${text}`);
|
|
181
|
+
}
|
|
182
|
+
const data = await res.json();
|
|
183
|
+
return data.members || {};
|
|
184
|
+
} finally {
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Merge a member map into the local peer list, replacing the previous snapshot
|
|
190
|
+
// of this group and skipping ourselves. Used by both leaders (local groups) and
|
|
191
|
+
// members (leader-pulled members).
|
|
192
|
+
// broadband members (relay://) are NOT added as direct peers — they are
|
|
193
|
+
// reached via the leader relay, so we skip them here.
|
|
194
|
+
export function syncPeersFromMembers({ peers, members, myUrl, group, skipIds = [] }) {
|
|
195
|
+
const self = normalizePeerUrl(myUrl);
|
|
196
|
+
const skip = new Set(skipIds);
|
|
197
|
+
const removed = peers.removeByGroup(group);
|
|
198
|
+
let added = 0;
|
|
199
|
+
for (const [id, m] of Object.entries(members || {})) {
|
|
200
|
+
if (skip.has(id)) continue;
|
|
201
|
+
const kind = m?.kind || "static";
|
|
202
|
+
if (kind === "broadband" || String(m?.url || "").startsWith("relay://")) continue;
|
|
203
|
+
const url = normalizePeerUrl(m?.url);
|
|
204
|
+
if (!url || url === self) continue;
|
|
205
|
+
if (peers.add({ name: id, url, token: m?.token || "", group })) added++;
|
|
206
|
+
}
|
|
207
|
+
return { removed, added, total: Object.keys(members || {}).length };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---- join-failure bans (per source IP) ----
|
|
211
|
+
|
|
212
|
+
export function createBansService({ file, now = () => Date.now(), windowMs = DEFAULT_BAN_WINDOW_MS, threshold = DEFAULT_BAN_THRESHOLD } = {}) {
|
|
213
|
+
let bans = loadBans(file ? { file } : {});
|
|
214
|
+
|
|
215
|
+
function prune() {
|
|
216
|
+
let changed = false;
|
|
217
|
+
for (const [ip, b] of Object.entries(bans)) {
|
|
218
|
+
if (b.bannedAt !== undefined && now() - b.bannedAt >= windowMs) {
|
|
219
|
+
delete bans[ip];
|
|
220
|
+
changed = true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (changed) saveBans(bans, file ? { file } : {});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isBanned(ip) {
|
|
227
|
+
if (!ip) return false;
|
|
228
|
+
prune();
|
|
229
|
+
const b = bans[ip];
|
|
230
|
+
if (b?.bannedAt === undefined) return false;
|
|
231
|
+
return { bannedAt: b.bannedAt, until: b.bannedAt + windowMs };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function recordFailure(ip) {
|
|
235
|
+
if (!ip) return null;
|
|
236
|
+
prune();
|
|
237
|
+
const b = (bans[ip] = bans[ip] || { fails: 0 });
|
|
238
|
+
b.fails = (b.fails || 0) + 1;
|
|
239
|
+
if (b.fails >= threshold) {
|
|
240
|
+
b.bannedAt = now();
|
|
241
|
+
b.fails = 0;
|
|
242
|
+
}
|
|
243
|
+
saveBans(bans, file ? { file } : {});
|
|
244
|
+
return b.bannedAt !== undefined ? b : null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function clear(ip) {
|
|
248
|
+
if (ip) {
|
|
249
|
+
delete bans[ip];
|
|
250
|
+
} else {
|
|
251
|
+
bans = {};
|
|
252
|
+
}
|
|
253
|
+
saveBans(bans, file ? { file } : {});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function list() {
|
|
257
|
+
prune();
|
|
258
|
+
return { ...bans };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return { isBanned, recordFailure, clear, list, windowMs, threshold };
|
|
262
|
+
}
|