@nordbyte/nordrelay 0.8.1 → 0.8.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/.env.example +9 -0
- package/README.md +81 -1206
- package/dist/{access-control.js → access/access-control.js} +1 -1
- package/dist/{audit-log.js → access/audit-log.js} +2 -2
- package/dist/{session-locks.js → access/session-locks.js} +1 -1
- package/dist/{user-management.js → access/user-management.js} +1 -1
- package/dist/{claude-code-cli.js → agents/claude-code/claude-code-cli.js} +2 -2
- package/dist/{claude-code-session.js → agents/claude-code/claude-code-session.js} +1 -1
- package/dist/{codex-cli.js → agents/codex/codex-cli.js} +14 -5
- package/dist/{codex-session.js → agents/codex/codex-session.js} +2 -4
- package/dist/{hermes-cli.js → agents/hermes/hermes-cli.js} +2 -2
- package/dist/{hermes-launch.js → agents/hermes/hermes-launch.js} +1 -1
- package/dist/{hermes-session.js → agents/hermes/hermes-session.js} +1 -1
- package/dist/{openclaw-cli.js → agents/openclaw/openclaw-cli.js} +2 -2
- package/dist/{openclaw-launch.js → agents/openclaw/openclaw-launch.js} +1 -1
- package/dist/{openclaw-session.js → agents/openclaw/openclaw-session.js} +1 -1
- package/dist/{pi-cli.js → agents/pi/pi-cli.js} +2 -2
- package/dist/{pi-launch.js → agents/pi/pi-launch.js} +1 -1
- package/dist/{pi-session.js → agents/pi/pi-session.js} +1 -1
- package/dist/{adapter-conformance.js → agents/shared/adapter-conformance.js} +2 -2
- package/dist/{agent-activity.js → agents/shared/agent-activity.js} +5 -5
- package/dist/agents/shared/agent-auth-commands.js +30 -0
- package/dist/{agent-factory.js → agents/shared/agent-factory.js} +5 -5
- package/dist/{agent-feature-matrix.js → agents/shared/agent-feature-matrix.js} +2 -2
- package/dist/{agent-updates.js → agents/shared/agent-updates.js} +7 -7
- package/dist/{discord-artifacts.js → channels/discord/discord-artifacts.js} +4 -4
- package/dist/{discord-bot.js → channels/discord/discord-bot.js} +164 -424
- package/dist/{discord-channel-runtime.js → channels/discord/discord-channel-runtime.js} +2 -2
- package/dist/{discord-command-surface.js → channels/discord/discord-command-surface.js} +3 -3
- package/dist/{bot-rendering.js → channels/shared/bot-rendering.js} +6 -6
- package/dist/{channel-actions.js → channels/shared/channel-actions.js} +4 -4
- package/dist/channels/shared/channel-bridge-controller.js +69 -0
- package/dist/channels/shared/channel-cli-artifacts.js +51 -0
- package/dist/{channel-command-service.js → channels/shared/channel-command-service.js} +51 -28
- package/dist/channels/shared/channel-external-mirror-controller.js +193 -0
- package/dist/channels/shared/channel-external-monitor.js +52 -0
- package/dist/{channel-mirror-registry.js → channels/shared/channel-mirror-registry.js} +14 -6
- package/dist/{channel-peer-prompt.js → channels/shared/channel-peer-prompt.js} +3 -3
- package/dist/{channel-turn-service.js → channels/shared/channel-turn-service.js} +2 -2
- package/dist/{context-key.js → channels/shared/context-key.js} +1 -1
- package/dist/{session-format.js → channels/shared/session-format.js} +2 -2
- package/dist/{slack-artifacts.js → channels/slack/slack-artifacts.js} +4 -4
- package/dist/{slack-bot.js → channels/slack/slack-bot.js} +159 -294
- package/dist/{slack-channel-runtime.js → channels/slack/slack-channel-runtime.js} +2 -2
- package/dist/{slack-command-surface.js → channels/slack/slack-command-surface.js} +2 -2
- package/dist/{slack-diagnostics.js → channels/slack/slack-diagnostics.js} +2 -2
- package/dist/{bot-ui.js → channels/telegram/bot-ui.js} +1 -1
- package/dist/{bot.js → channels/telegram/bot.js} +178 -427
- package/dist/{telegram-access-commands.js → channels/telegram/telegram-access-commands.js} +3 -3
- package/dist/{telegram-access-middleware.js → channels/telegram/telegram-access-middleware.js} +4 -4
- package/dist/{telegram-agent-commands.js → channels/telegram/telegram-agent-commands.js} +9 -9
- package/dist/{telegram-artifact-commands.js → channels/telegram/telegram-artifact-commands.js} +4 -4
- package/dist/{telegram-channel-runtime.js → channels/telegram/telegram-channel-runtime.js} +2 -2
- package/dist/{telegram-command-menu.js → channels/telegram/telegram-command-menu.js} +1 -1
- package/dist/{telegram-diagnostics-command.js → channels/telegram/telegram-diagnostics-command.js} +7 -7
- package/dist/{telegram-general-commands.js → channels/telegram/telegram-general-commands.js} +4 -4
- package/dist/{telegram-operational-commands.js → channels/telegram/telegram-operational-commands.js} +5 -5
- package/dist/{telegram-output.js → channels/telegram/telegram-output.js} +2 -2
- package/dist/{telegram-preference-commands.js → channels/telegram/telegram-preference-commands.js} +3 -3
- package/dist/{telegram-queue-commands.js → channels/telegram/telegram-queue-commands.js} +6 -6
- package/dist/{telegram-support-command.js → channels/telegram/telegram-support-command.js} +4 -4
- package/dist/{telegram-update-commands.js → channels/telegram/telegram-update-commands.js} +5 -5
- package/dist/{config-metadata.js → core/config-metadata.js} +8 -0
- package/dist/{config.js → core/config.js} +11 -3
- package/dist/index.js +27 -23
- package/dist/peers/peer-discovery-jobs.js +206 -0
- package/dist/peers/peer-discovery.js +223 -0
- package/dist/peers/peer-health-monitor.js +49 -0
- package/dist/{peer-identity.js → peers/peer-identity.js} +50 -1
- package/dist/{peer-runtime-service.js → peers/peer-runtime-service.js} +29 -7
- package/dist/{peer-server.js → peers/peer-server.js} +3 -2
- package/dist/{peer-store.js → peers/peer-store.js} +80 -9
- package/dist/{peer-types.js → peers/peer-types.js} +9 -0
- package/dist/peers/peer-web-proxy-contract.js +127 -0
- package/dist/{metrics.js → runtime/metrics.js} +5 -3
- package/dist/{relay-artifact-service.js → runtime/relay-artifact-service.js} +1 -1
- package/dist/runtime/relay-auth-service.js +63 -0
- package/dist/runtime/relay-dashboard-service.js +139 -0
- package/dist/{relay-external-activity-monitor.js → runtime/relay-external-activity-monitor.js} +140 -53
- package/dist/runtime/relay-runtime-active-sessions.js +387 -0
- package/dist/runtime/relay-runtime-dashboard.js +201 -0
- package/dist/runtime/relay-runtime-prompt-queue-artifacts.js +307 -0
- package/dist/runtime/relay-runtime-sessions.js +623 -0
- package/dist/runtime/relay-runtime-types.js +1 -0
- package/dist/runtime/relay-runtime-updates-jobs.js +360 -0
- package/dist/runtime/relay-runtime.js +451 -0
- package/dist/runtime/runtime-cache.js +117 -0
- package/dist/{session-registry.js → state/session-registry.js} +3 -3
- package/dist/{operations.js → support/operations.js} +7 -7
- package/dist/{support-bundle.js → support/support-bundle.js} +1 -1
- package/dist/{web-api-contract.js → web/web-api-contract.js} +17 -3
- package/dist/web/web-api-types.js +1 -0
- package/dist/{web-dashboard-access-routes.js → web/web-dashboard-access-routes.js} +2 -2
- package/dist/{web-dashboard-assets.js → web/web-dashboard-assets.js} +24 -2
- package/dist/{web-dashboard-http.js → web/web-dashboard-http.js} +41 -5
- package/dist/{web-dashboard-pages.js → web/web-dashboard-pages.js} +37 -10
- package/dist/{web-dashboard-peer-routes.js → web/web-dashboard-peer-routes.js} +102 -7
- package/dist/web/web-dashboard-security.js +14 -0
- package/dist/{web-dashboard-session-routes.js → web/web-dashboard-session-routes.js} +12 -1
- package/dist/{web-dashboard.js → web/web-dashboard.js} +132 -48
- package/dist/web/web-performance.js +60 -0
- package/dist/web/web-rate-limit.js +19 -0
- package/dist/{web-state.js → web/web-state.js} +74 -5
- package/dist/webui-assets/dashboard.css +171 -10
- package/dist/webui-assets/dashboard.js +514 -48
- package/dist/webui-assets/favicon.ico +0 -0
- package/dist/webui-assets/favicon.png +0 -0
- package/dist/webui-assets/logo.png +0 -0
- package/package.json +4 -3
- package/plugins/nordrelay/scripts/nordrelay.mjs +13 -4
- package/{launchd/start.sh → scripts/launchd-start.sh} +1 -1
- package/dist/relay-runtime.js +0 -1916
- package/dist/runtime-cache.js +0 -57
- /package/dist/{user-management-crypto.js → access/user-management-crypto.js} +0 -0
- /package/dist/{user-management-normalize.js → access/user-management-normalize.js} +0 -0
- /package/dist/{user-management-types.js → access/user-management-types.js} +0 -0
- /package/dist/{claude-code-auth.js → agents/claude-code/claude-code-auth.js} +0 -0
- /package/dist/{claude-code-launch.js → agents/claude-code/claude-code-launch.js} +0 -0
- /package/dist/{claude-code-state.js → agents/claude-code/claude-code-state.js} +0 -0
- /package/dist/{codex-auth.js → agents/codex/codex-auth.js} +0 -0
- /package/dist/{codex-config.js → agents/codex/codex-config.js} +0 -0
- /package/dist/{codex-launch.js → agents/codex/codex-launch.js} +0 -0
- /package/dist/{codex-state.js → agents/codex/codex-state.js} +0 -0
- /package/dist/{hermes-api.js → agents/hermes/hermes-api.js} +0 -0
- /package/dist/{hermes-auth.js → agents/hermes/hermes-auth.js} +0 -0
- /package/dist/{hermes-state.js → agents/hermes/hermes-state.js} +0 -0
- /package/dist/{openclaw-auth.js → agents/openclaw/openclaw-auth.js} +0 -0
- /package/dist/{openclaw-gateway.js → agents/openclaw/openclaw-gateway.js} +0 -0
- /package/dist/{openclaw-state.js → agents/openclaw/openclaw-state.js} +0 -0
- /package/dist/{pi-auth.js → agents/pi/pi-auth.js} +0 -0
- /package/dist/{pi-rpc.js → agents/pi/pi-rpc.js} +0 -0
- /package/dist/{pi-state.js → agents/pi/pi-state.js} +0 -0
- /package/dist/{agent-adapter.js → agents/shared/agent-adapter.js} +0 -0
- /package/dist/{agent.js → agents/shared/agent.js} +0 -0
- /package/dist/{artifacts.js → artifacts/artifacts.js} +0 -0
- /package/dist/{attachments.js → artifacts/attachments.js} +0 -0
- /package/dist/{voice.js → artifacts/voice.js} +0 -0
- /package/dist/{discord-rate-limit.js → channels/discord/discord-rate-limit.js} +0 -0
- /package/dist/{channel-adapter.js → channels/shared/channel-adapter.js} +0 -0
- /package/dist/{relay-runtime-types.js → channels/shared/channel-bridge-state.js} +0 -0
- /package/dist/{channel-command-catalog.js → channels/shared/channel-command-catalog.js} +0 -0
- /package/dist/{channel-command-core.js → channels/shared/channel-command-core.js} +0 -0
- /package/dist/{channel-prompt-engine.js → channels/shared/channel-prompt-engine.js} +0 -0
- /package/dist/{channel-runtime.js → channels/shared/channel-runtime.js} +0 -0
- /package/dist/{channel-turn-lifecycle.js → channels/shared/channel-turn-lifecycle.js} +0 -0
- /package/dist/{slack-rate-limit.js → channels/slack/slack-rate-limit.js} +0 -0
- /package/dist/{telegram-command-types.js → channels/telegram/telegram-command-types.js} +0 -0
- /package/dist/{telegram-rate-limit.js → channels/telegram/telegram-rate-limit.js} +0 -0
- /package/dist/{activity-events.js → core/activity-events.js} +0 -0
- /package/dist/{error-messages.js → core/error-messages.js} +0 -0
- /package/dist/{format.js → core/format.js} +0 -0
- /package/dist/{logger.js → core/logger.js} +0 -0
- /package/dist/{redaction.js → core/redaction.js} +0 -0
- /package/dist/{settings-service.js → core/settings-service.js} +0 -0
- /package/dist/{settings-wizard-test.js → core/settings-wizard-test.js} +0 -0
- /package/dist/{workspace-policy.js → core/workspace-policy.js} +0 -0
- /package/dist/{peer-auth.js → peers/peer-auth.js} +0 -0
- /package/dist/{peer-client.js → peers/peer-client.js} +0 -0
- /package/dist/{peer-context.js → peers/peer-context.js} +0 -0
- /package/dist/{peer-readiness.js → peers/peer-readiness.js} +0 -0
- /package/dist/{relay-queue-service.js → runtime/relay-queue-service.js} +0 -0
- /package/dist/{web-api-types.js → runtime/relay-runtime-delegate.js} +0 -0
- /package/dist/{relay-runtime-helpers.js → runtime/relay-runtime-helpers.js} +0 -0
- /package/dist/{remote-prompt.js → runtime/remote-prompt.js} +0 -0
- /package/dist/{bot-preferences.js → state/bot-preferences.js} +0 -0
- /package/dist/{job-store.js → state/job-store.js} +0 -0
- /package/dist/{persistence.js → state/persistence.js} +0 -0
- /package/dist/{prompt-store.js → state/prompt-store.js} +0 -0
- /package/dist/{state-backend.js → state/state-backend.js} +0 -0
- /package/dist/{zip-writer.js → support/zip-writer.js} +0 -0
- /package/dist/{web-dashboard-artifact-routes.js → web/web-dashboard-artifact-routes.js} +0 -0
- /package/dist/{web-dashboard-runtime-routes.js → web/web-dashboard-runtime-routes.js} +0 -0
- /package/dist/{web-dashboard-ui.js → web/web-dashboard-ui.js} +0 -0
|
@@ -1,47 +1,68 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
|
-
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { URL } from "node:url";
|
|
6
|
-
import { enabledAgents } from "
|
|
7
|
-
import { buildAdapterConformanceMatrix } from "
|
|
8
|
-
import { listAgentAdapterDescriptors } from "
|
|
9
|
-
import { isAgentId } from "
|
|
10
|
-
import { AuditLogStore } from "
|
|
11
|
-
import { listChannelDescriptors } from "
|
|
12
|
-
import { permissionForWebRequest } from "
|
|
13
|
-
import { loadConfig } from "
|
|
14
|
-
import { friendlyErrorText } from "
|
|
15
|
-
import { RelayRuntime } from "
|
|
16
|
-
import { resolveDashboardEnvPath, SettingsService } from "
|
|
17
|
-
import { mergeSettingsWizardTestSettings, runSettingsWizardTest } from "
|
|
18
|
-
import { UserStore, publicUser } from "
|
|
6
|
+
import { enabledAgents } from "../agents/shared/agent-factory.js";
|
|
7
|
+
import { buildAdapterConformanceMatrix } from "../agents/shared/adapter-conformance.js";
|
|
8
|
+
import { listAgentAdapterDescriptors } from "../agents/shared/agent-adapter.js";
|
|
9
|
+
import { isAgentId } from "../agents/shared/agent.js";
|
|
10
|
+
import { AuditLogStore } from "../access/audit-log.js";
|
|
11
|
+
import { listChannelDescriptors } from "../channels/shared/channel-adapter.js";
|
|
12
|
+
import { permissionForWebRequest } from "../access/access-control.js";
|
|
13
|
+
import { loadConfig } from "../core/config.js";
|
|
14
|
+
import { friendlyErrorText } from "../core/error-messages.js";
|
|
15
|
+
import { RelayRuntime } from "../runtime/relay-runtime.js";
|
|
16
|
+
import { resolveDashboardEnvPath, SettingsService } from "../core/settings-service.js";
|
|
17
|
+
import { mergeSettingsWizardTestSettings, runSettingsWizardTest } from "../core/settings-wizard-test.js";
|
|
18
|
+
import { UserStore, publicUser } from "../access/user-management.js";
|
|
19
19
|
import { handleDashboardAccessRoute } from "./web-dashboard-access-routes.js";
|
|
20
20
|
import { handleDashboardArtifactRoute } from "./web-dashboard-artifact-routes.js";
|
|
21
|
-
import { dashboardCss, dashboardJs } from "./web-dashboard-assets.js";
|
|
22
|
-
import { objectRecord, optionalStringField, parseCookies, readJsonBody, sendJson, sendText, } from "./web-dashboard-http.js";
|
|
21
|
+
import { dashboardCss, dashboardJs, dashboardStaticAsset } from "./web-dashboard-assets.js";
|
|
22
|
+
import { objectRecord, optionalStringField, parseCookies, readJsonBody, sendJson, sendText, sendStaticFile, isRequestBodyTooLargeError, } from "./web-dashboard-http.js";
|
|
23
23
|
import { renderDashboardApp, renderFirstRunSetupPage, renderLoginPage } from "./web-dashboard-pages.js";
|
|
24
24
|
import { handleDashboardRuntimeRoute } from "./web-dashboard-runtime-routes.js";
|
|
25
25
|
import { handleDashboardSessionRoute } from "./web-dashboard-session-routes.js";
|
|
26
26
|
import { handleDashboardPeerRoute } from "./web-dashboard-peer-routes.js";
|
|
27
|
+
import { PeerDiscoveryJobManager } from "../peers/peer-discovery-jobs.js";
|
|
28
|
+
import { recordWebApiMetric } from "./web-performance.js";
|
|
29
|
+
import { createCspNonce, isMutatingWebApiRequest, requiresWebCsrf } from "./web-dashboard-security.js";
|
|
30
|
+
import { consumeRateLimit, resetRateLimit } from "./web-rate-limit.js";
|
|
27
31
|
const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
|
|
32
|
+
const WEB_API_MUTATION_LIMIT = 240;
|
|
33
|
+
const WEB_API_MUTATION_WINDOW_MS = 60_000;
|
|
34
|
+
const WEB_API_MUTATION_BLOCK_MS = 60_000;
|
|
28
35
|
const options = parseOptions(process.argv.slice(2));
|
|
29
36
|
const config = loadConfig();
|
|
30
37
|
const runtime = new RelayRuntime(config);
|
|
31
38
|
const settings = new SettingsService(resolveDashboardEnvPath(options.home));
|
|
32
39
|
const users = new UserStore(options.home);
|
|
33
40
|
const auditLog = new AuditLogStore(config.workspace, config.stateBackend, config.auditMaxEvents);
|
|
41
|
+
const peerDiscoveryJobs = new PeerDiscoveryJobManager(config, options.home);
|
|
34
42
|
const loginAttempts = new Map();
|
|
43
|
+
const apiMutationAttempts = new Map();
|
|
35
44
|
const firstRunSetupToken = users.hasAdminUser() ? undefined : randomBytes(18).toString("base64url");
|
|
36
45
|
const firstRunSetupRequiresToken = !isLoopbackHost(options.host);
|
|
46
|
+
const csrfSecret = randomBytes(32).toString("base64url");
|
|
37
47
|
if (firstRunSetupToken) {
|
|
38
48
|
console.log(`NordRelay first-run setup token: ${firstRunSetupToken}`);
|
|
39
49
|
}
|
|
40
50
|
class AccessDeniedError extends Error {
|
|
41
51
|
}
|
|
42
52
|
const server = createServer((req, res) => {
|
|
53
|
+
const startedAt = Date.now();
|
|
54
|
+
const pathName = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`).pathname;
|
|
55
|
+
res.on("finish", () => {
|
|
56
|
+
recordWebApiMetric({
|
|
57
|
+
method: req.method ?? "GET",
|
|
58
|
+
path: pathName,
|
|
59
|
+
statusCode: res.statusCode,
|
|
60
|
+
durationMs: Date.now() - startedAt,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
43
63
|
void handleRequest(req, res).catch((error) => {
|
|
44
|
-
|
|
64
|
+
const status = error instanceof AccessDeniedError ? 403 : isRequestBodyTooLargeError(error) ? 413 : 500;
|
|
65
|
+
sendJson(res, status, { error: friendlyErrorText(error) });
|
|
45
66
|
});
|
|
46
67
|
});
|
|
47
68
|
await new Promise((resolve) => server.listen(options.port, options.host, resolve));
|
|
@@ -62,27 +83,52 @@ async function handleRequest(req, res) {
|
|
|
62
83
|
handleLogout(req, res);
|
|
63
84
|
return;
|
|
64
85
|
}
|
|
86
|
+
if (servePublicDashboardAsset(url.pathname, res)) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
65
89
|
const authenticated = authenticateRequest(req);
|
|
66
90
|
if (url.pathname === "/api/auth/me" && req.method === "GET") {
|
|
67
91
|
if (!authenticated) {
|
|
68
92
|
sendJson(res, 401, { error: "Authentication required", adminConfigured: users.hasAdminUser() });
|
|
69
93
|
return;
|
|
70
94
|
}
|
|
71
|
-
sendJson(res, 200, currentUserDto(authenticated));
|
|
95
|
+
sendJson(res, 200, currentUserDto(authenticated, req));
|
|
72
96
|
return;
|
|
73
97
|
}
|
|
74
98
|
if (!authenticated) {
|
|
75
99
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
100
|
+
const cspNonce = createCspNonce();
|
|
76
101
|
if (!users.hasAdminUser()) {
|
|
77
|
-
sendText(res, 200, renderFirstRunSetupPage({ tokenRequired: firstRunSetupRequiresToken || !isLoopbackRequest(req) }), "text/html; charset=utf-8");
|
|
102
|
+
sendText(res, 200, renderFirstRunSetupPage({ tokenRequired: firstRunSetupRequiresToken || !isLoopbackRequest(req), cspNonce }), "text/html; charset=utf-8", { cspNonce });
|
|
78
103
|
return;
|
|
79
104
|
}
|
|
80
|
-
sendText(res, 200, renderLoginPage({ adminConfigured: users.hasAdminUser() }), "text/html; charset=utf-8");
|
|
105
|
+
sendText(res, 200, renderLoginPage({ adminConfigured: users.hasAdminUser(), cspNonce }), "text/html; charset=utf-8", { cspNonce });
|
|
81
106
|
return;
|
|
82
107
|
}
|
|
83
108
|
sendJson(res, 401, { error: "Authentication required", adminConfigured: users.hasAdminUser() });
|
|
84
109
|
return;
|
|
85
110
|
}
|
|
111
|
+
if (isMutatingWebApiRequest(req.method, url.pathname)) {
|
|
112
|
+
const limited = consumeRateLimit(apiMutationAttempts, `${req.socket.remoteAddress ?? "unknown"}:${authenticated.user.id}`, WEB_API_MUTATION_LIMIT, WEB_API_MUTATION_WINDOW_MS, WEB_API_MUTATION_BLOCK_MS);
|
|
113
|
+
if (limited.limited) {
|
|
114
|
+
sendJson(res, 429, { error: "Too many API changes. Try again later.", retryAfterMs: limited.retryAfterMs });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (requiresCsrf(req, url) && !verifyCsrf(req)) {
|
|
119
|
+
audit({
|
|
120
|
+
action: "permission_denied",
|
|
121
|
+
status: "denied",
|
|
122
|
+
channelId: "web",
|
|
123
|
+
contextKey: "web",
|
|
124
|
+
actor: webActivityActor(authenticated),
|
|
125
|
+
actorId: authenticated.user.id,
|
|
126
|
+
actorRole: authenticated.groups.map((group) => group.name).join(", "),
|
|
127
|
+
description: `Invalid CSRF token for ${req.method ?? "GET"} ${url.pathname}`,
|
|
128
|
+
});
|
|
129
|
+
sendJson(res, 403, { error: "Invalid CSRF token." });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
86
132
|
if (url.pathname === "/healthz") {
|
|
87
133
|
if (!users.hasPermission(authenticated, "inspect")) {
|
|
88
134
|
sendText(res, 403, "access denied\n", "text/plain; charset=utf-8");
|
|
@@ -92,7 +138,8 @@ async function handleRequest(req, res) {
|
|
|
92
138
|
return;
|
|
93
139
|
}
|
|
94
140
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
95
|
-
|
|
141
|
+
const cspNonce = createCspNonce();
|
|
142
|
+
sendText(res, 200, renderDashboardApp({ cspNonce }), "text/html; charset=utf-8", { cspNonce });
|
|
96
143
|
return;
|
|
97
144
|
}
|
|
98
145
|
if (url.pathname === "/assets/dashboard.css") {
|
|
@@ -113,6 +160,25 @@ async function handleRequest(req, res) {
|
|
|
113
160
|
}
|
|
114
161
|
await handleApi(req, res, url, authenticated);
|
|
115
162
|
}
|
|
163
|
+
function servePublicDashboardAsset(pathname, res) {
|
|
164
|
+
const assetName = pathname === "/favicon.ico"
|
|
165
|
+
? "favicon.ico"
|
|
166
|
+
: pathname === "/assets/favicon.png"
|
|
167
|
+
? "favicon.png"
|
|
168
|
+
: pathname === "/assets/logo.png"
|
|
169
|
+
? "logo.png"
|
|
170
|
+
: null;
|
|
171
|
+
if (!assetName) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
const asset = dashboardStaticAsset(assetName);
|
|
175
|
+
if (!asset) {
|
|
176
|
+
sendText(res, 404, "not found\n", "text/plain; charset=utf-8");
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
sendStaticFile(res, asset.filePath, asset.contentType);
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
116
182
|
async function handleApi(req, res, url, authUser) {
|
|
117
183
|
const permission = permissionForWebRequest(req.method, url.pathname);
|
|
118
184
|
if (!permission) {
|
|
@@ -160,7 +226,7 @@ async function handleApi(req, res, url, authUser) {
|
|
|
160
226
|
if (req.method === "GET" && url.pathname === "/api/bootstrap") {
|
|
161
227
|
await assertCurrentSessionScope(authUser);
|
|
162
228
|
sendJson(res, 200, {
|
|
163
|
-
auth: currentUserDto(authUser),
|
|
229
|
+
auth: currentUserDto(authUser, req),
|
|
164
230
|
channels: listChannelDescriptors(),
|
|
165
231
|
agentAdapters: listAgentAdapterDescriptors().filter((adapter) => users.canUseAgent(authUser, adapter.id)),
|
|
166
232
|
adapterConformance: scopedAdapterConformance(authUser),
|
|
@@ -192,6 +258,7 @@ async function handleApi(req, res, url, authUser) {
|
|
|
192
258
|
config,
|
|
193
259
|
home: options.home,
|
|
194
260
|
runtime,
|
|
261
|
+
discoveryJobs: peerDiscoveryJobs,
|
|
195
262
|
activityActor: webActivityActor(authUser),
|
|
196
263
|
auditPeerAction: (action, description) => auditUserAction(authUser, action, description),
|
|
197
264
|
})) {
|
|
@@ -330,8 +397,8 @@ async function handleFirstRunSetup(req, res) {
|
|
|
330
397
|
actor: webActivityActor(authUser),
|
|
331
398
|
detail: authUser.user.email,
|
|
332
399
|
});
|
|
333
|
-
setSessionCookie(res, session.token);
|
|
334
|
-
sendJson(res, 201, currentUserDto(authUser));
|
|
400
|
+
setSessionCookie(res, session.token, req);
|
|
401
|
+
sendJson(res, 201, currentUserDto(authUser, undefined, session.token));
|
|
335
402
|
}
|
|
336
403
|
async function handleLogin(req, res) {
|
|
337
404
|
const body = await readJsonBody(req);
|
|
@@ -387,8 +454,8 @@ async function handleLogin(req, res) {
|
|
|
387
454
|
actor: webActivityActor(authUser),
|
|
388
455
|
detail: authUser.user.email,
|
|
389
456
|
});
|
|
390
|
-
setSessionCookie(res, session.token);
|
|
391
|
-
sendJson(res, 200, currentUserDto(authUser));
|
|
457
|
+
setSessionCookie(res, session.token, req);
|
|
458
|
+
sendJson(res, 200, currentUserDto(authUser, undefined, session.token));
|
|
392
459
|
}
|
|
393
460
|
function isLoopbackRequest(req) {
|
|
394
461
|
const address = req.socket.remoteAddress ?? "";
|
|
@@ -402,6 +469,10 @@ function isLoopbackHost(host) {
|
|
|
402
469
|
}
|
|
403
470
|
function handleLogout(req, res) {
|
|
404
471
|
const authUser = authenticateRequest(req);
|
|
472
|
+
if (authUser && !verifyCsrf(req)) {
|
|
473
|
+
sendJson(res, 403, { error: "Invalid CSRF token." });
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
405
476
|
users.destroyWebSession(parseCookies(req.headers.cookie ?? "").nr_session);
|
|
406
477
|
if (authUser) {
|
|
407
478
|
auditUserAction(authUser, "auth_logout", authUser.user.email);
|
|
@@ -431,19 +502,49 @@ function authenticateRequest(req) {
|
|
|
431
502
|
const cookies = parseCookies(req.headers.cookie ?? "");
|
|
432
503
|
return users.resolveWebSession(cookies.nr_session);
|
|
433
504
|
}
|
|
434
|
-
function setSessionCookie(res, token) {
|
|
435
|
-
|
|
505
|
+
function setSessionCookie(res, token, req) {
|
|
506
|
+
const secure = req && isHttpsRequest(req) ? "; Secure" : "";
|
|
507
|
+
res.setHeader("set-cookie", `nr_session=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/${secure}`);
|
|
436
508
|
}
|
|
437
509
|
function clearSessionCookie(res) {
|
|
438
510
|
res.setHeader("set-cookie", "nr_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0");
|
|
439
511
|
}
|
|
440
|
-
function
|
|
512
|
+
function isHttpsRequest(req) {
|
|
513
|
+
return Boolean(req.socket.encrypted) ||
|
|
514
|
+
String(req.headers["x-forwarded-proto"] ?? "").split(",")[0]?.trim().toLowerCase() === "https";
|
|
515
|
+
}
|
|
516
|
+
function currentUserDto(authUser, req, sessionToken) {
|
|
517
|
+
const token = sessionToken ?? (req ? parseCookies(req.headers.cookie ?? "").nr_session : undefined);
|
|
441
518
|
return {
|
|
442
519
|
user: publicUser(authUser.user),
|
|
443
520
|
groups: authUser.groups,
|
|
444
521
|
permissions: authUser.permissions,
|
|
522
|
+
csrfToken: token ? csrfTokenForSession(token) : undefined,
|
|
445
523
|
};
|
|
446
524
|
}
|
|
525
|
+
function requiresCsrf(req, url) {
|
|
526
|
+
return requiresWebCsrf(req.method, url.pathname);
|
|
527
|
+
}
|
|
528
|
+
function verifyCsrf(req) {
|
|
529
|
+
const sessionToken = parseCookies(req.headers.cookie ?? "").nr_session;
|
|
530
|
+
const supplied = headerValue(req, "x-nordrelay-csrf");
|
|
531
|
+
if (!sessionToken || !supplied) {
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
return safeEqualString(supplied, csrfTokenForSession(sessionToken));
|
|
535
|
+
}
|
|
536
|
+
function csrfTokenForSession(sessionToken) {
|
|
537
|
+
return createHmac("sha256", csrfSecret).update(sessionToken).digest("base64url");
|
|
538
|
+
}
|
|
539
|
+
function headerValue(req, name) {
|
|
540
|
+
const value = req.headers[name.toLowerCase()];
|
|
541
|
+
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
|
|
542
|
+
}
|
|
543
|
+
function safeEqualString(left, right) {
|
|
544
|
+
const leftBuffer = Buffer.from(left);
|
|
545
|
+
const rightBuffer = Buffer.from(right);
|
|
546
|
+
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
|
547
|
+
}
|
|
447
548
|
function audit(event) {
|
|
448
549
|
try {
|
|
449
550
|
auditLog.append(event);
|
|
@@ -612,25 +713,6 @@ async function assertCurrentSessionScope(authUser) {
|
|
|
612
713
|
function objectValue(value) {
|
|
613
714
|
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
614
715
|
}
|
|
615
|
-
function consumeRateLimit(buckets, key, limit, windowMs, blockMs) {
|
|
616
|
-
const now = Date.now();
|
|
617
|
-
const existing = buckets.get(key);
|
|
618
|
-
if (existing?.blockedUntil && existing.blockedUntil > now) {
|
|
619
|
-
return { limited: true, retryAfterMs: existing.blockedUntil - now };
|
|
620
|
-
}
|
|
621
|
-
const bucket = !existing || existing.resetAt <= now ? { count: 0, resetAt: now + windowMs } : existing;
|
|
622
|
-
bucket.count += 1;
|
|
623
|
-
if (bucket.count > limit) {
|
|
624
|
-
bucket.blockedUntil = now + blockMs;
|
|
625
|
-
buckets.set(key, bucket);
|
|
626
|
-
return { limited: true, retryAfterMs: blockMs };
|
|
627
|
-
}
|
|
628
|
-
buckets.set(key, bucket);
|
|
629
|
-
return { limited: false };
|
|
630
|
-
}
|
|
631
|
-
function resetRateLimit(buckets, key) {
|
|
632
|
-
buckets.delete(key);
|
|
633
|
-
}
|
|
634
716
|
function parseAgentId(value) {
|
|
635
717
|
if (!value) {
|
|
636
718
|
return undefined;
|
|
@@ -742,6 +824,8 @@ function activeSettingsValues(current) {
|
|
|
742
824
|
TELEGRAM_EDIT_MIN_INTERVAL_MS: String(current.telegramEditMinIntervalMs),
|
|
743
825
|
NORDRELAY_CLI_MIRROR_MODE: current.mirrorMode,
|
|
744
826
|
NORDRELAY_CLI_MIRROR_MIN_UPDATE_MS: String(current.mirrorMinUpdateMs),
|
|
827
|
+
NORDRELAY_WEB_CLI_MIRROR_MODE: current.webMirrorMode === current.mirrorMode ? "" : current.webMirrorMode,
|
|
828
|
+
NORDRELAY_WEB_CLI_MIRROR_MIN_UPDATE_MS: current.webMirrorMinUpdateMs === current.mirrorMinUpdateMs ? "" : String(current.webMirrorMinUpdateMs),
|
|
745
829
|
NORDRELAY_NOTIFY_MODE: current.notifyMode,
|
|
746
830
|
NORDRELAY_QUIET_HOURS: quietValue(current.quietHours),
|
|
747
831
|
NORDRELAY_AUTO_SEND_ARTIFACTS: boolValue(current.autoSendArtifacts),
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const recent = [];
|
|
2
|
+
const routeMetrics = new Map();
|
|
3
|
+
const MAX_RECENT = 200;
|
|
4
|
+
export function recordWebApiMetric(sample) {
|
|
5
|
+
const next = {
|
|
6
|
+
...sample,
|
|
7
|
+
durationMs: Math.max(0, Math.round(sample.durationMs)),
|
|
8
|
+
at: sample.at ?? new Date().toISOString(),
|
|
9
|
+
};
|
|
10
|
+
recent.push(next);
|
|
11
|
+
if (recent.length > MAX_RECENT) {
|
|
12
|
+
recent.splice(0, recent.length - MAX_RECENT);
|
|
13
|
+
}
|
|
14
|
+
const key = `${next.method} ${routeKey(next.path)}`;
|
|
15
|
+
const existing = routeMetrics.get(key);
|
|
16
|
+
if (!existing) {
|
|
17
|
+
routeMetrics.set(key, {
|
|
18
|
+
method: next.method,
|
|
19
|
+
path: routeKey(next.path),
|
|
20
|
+
count: 1,
|
|
21
|
+
averageMs: next.durationMs,
|
|
22
|
+
maxMs: next.durationMs,
|
|
23
|
+
lastMs: next.durationMs,
|
|
24
|
+
lastStatusCode: next.statusCode,
|
|
25
|
+
lastAt: next.at,
|
|
26
|
+
totalMs: next.durationMs,
|
|
27
|
+
});
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
existing.count += 1;
|
|
31
|
+
existing.totalMs += next.durationMs;
|
|
32
|
+
existing.averageMs = Math.round(existing.totalMs / existing.count);
|
|
33
|
+
existing.maxMs = Math.max(existing.maxMs, next.durationMs);
|
|
34
|
+
existing.lastMs = next.durationMs;
|
|
35
|
+
existing.lastStatusCode = next.statusCode;
|
|
36
|
+
existing.lastAt = next.at;
|
|
37
|
+
}
|
|
38
|
+
export function getWebApiPerformanceMetrics() {
|
|
39
|
+
return {
|
|
40
|
+
recent: [...recent].reverse().slice(0, 25),
|
|
41
|
+
slowest: [...recent].sort((left, right) => right.durationMs - left.durationMs).slice(0, 10),
|
|
42
|
+
routes: [...routeMetrics.values()]
|
|
43
|
+
.map(({ totalMs: _totalMs, ...metric }) => ({ ...metric }))
|
|
44
|
+
.sort((left, right) => right.averageMs - left.averageMs)
|
|
45
|
+
.slice(0, 25),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function routeKey(path) {
|
|
49
|
+
return path
|
|
50
|
+
.replace(/\/api\/peers\/[^/]+\/proxy$/, "/api/peers/:id/proxy")
|
|
51
|
+
.replace(/\/api\/peers\/[^/]+\/events$/, "/api/peers/:id/events")
|
|
52
|
+
.replace(/\/api\/peers\/[^/]+\/health$/, "/api/peers/:id/health")
|
|
53
|
+
.replace(/\/api\/peers\/[^/]+\/repin$/, "/api/peers/:id/repin")
|
|
54
|
+
.replace(/\/api\/agent-update\/[^/]+\/(log|input|cancel)$/, "/api/agent-update/:id/$1")
|
|
55
|
+
.replace(/\/api\/jobs\/[^/]+\/(log|action)$/, "/api/jobs/:id/$1")
|
|
56
|
+
.replace(/\/api\/users\/[^/]+\/sessions\/[^/]+$/, "/api/users/:id/sessions/:sessionId")
|
|
57
|
+
.replace(/\/api\/users\/[^/]+\/(password|telegram|discord|slack|sessions)$/, "/api/users/:id/$1")
|
|
58
|
+
.replace(/\/api\/peers\/discovery-jobs\/[^/]+\/(cancel|log)$/, "/api/peers/discovery-jobs/:id/$1")
|
|
59
|
+
.replace(/\/api\/peers\/discovery-jobs\/[^/]+$/, "/api/peers/discovery-jobs/:id");
|
|
60
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function consumeRateLimit(buckets, key, maxAttempts, windowMs, blockMs, now = Date.now()) {
|
|
2
|
+
const existing = buckets.get(key);
|
|
3
|
+
if (existing?.blockedUntil && existing.blockedUntil > now) {
|
|
4
|
+
return { limited: true, retryAfterMs: existing.blockedUntil - now };
|
|
5
|
+
}
|
|
6
|
+
if (!existing || existing.resetAt <= now) {
|
|
7
|
+
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
|
8
|
+
return { limited: false };
|
|
9
|
+
}
|
|
10
|
+
existing.count += 1;
|
|
11
|
+
if (existing.count > maxAttempts) {
|
|
12
|
+
existing.blockedUntil = now + blockMs;
|
|
13
|
+
return { limited: true, retryAfterMs: blockMs };
|
|
14
|
+
}
|
|
15
|
+
return { limited: false };
|
|
16
|
+
}
|
|
17
|
+
export function resetRateLimit(buckets, key) {
|
|
18
|
+
buckets.delete(key);
|
|
19
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { activityActorLabel, activityCategoryForType, } from "
|
|
3
|
-
import { createDocumentStore } from "
|
|
2
|
+
import { activityActorLabel, activityCategoryForType, } from "../core/activity-events.js";
|
|
3
|
+
import { createDocumentStore } from "../state/state-backend.js";
|
|
4
4
|
const DEFAULT_CHAT_LIMIT = 300;
|
|
5
5
|
const DEFAULT_ACTIVITY_LIMIT = 1000;
|
|
6
6
|
export class WebChatStore {
|
|
@@ -16,9 +16,16 @@ export class WebChatStore {
|
|
|
16
16
|
this.maxMessages = maxMessages;
|
|
17
17
|
}
|
|
18
18
|
append(input) {
|
|
19
|
+
return this.appendWithResult(input).message;
|
|
20
|
+
}
|
|
21
|
+
appendWithResult(input) {
|
|
19
22
|
const payload = this.readPayload();
|
|
20
23
|
const threadId = input.threadId || "pending";
|
|
21
24
|
const messages = payload.messagesByThread[threadId] ?? [];
|
|
25
|
+
const duplicate = findDuplicateWebChatMessage(messages, { ...input, threadId });
|
|
26
|
+
if (duplicate) {
|
|
27
|
+
return { message: duplicate, inserted: false };
|
|
28
|
+
}
|
|
22
29
|
const message = {
|
|
23
30
|
id: randomId(),
|
|
24
31
|
timestamp: input.timestamp ?? new Date().toISOString(),
|
|
@@ -31,7 +38,37 @@ export class WebChatStore {
|
|
|
31
38
|
}
|
|
32
39
|
payload.messagesByThread[threadId] = messages;
|
|
33
40
|
this.store.write(payload);
|
|
34
|
-
return message;
|
|
41
|
+
return { message, inserted: true };
|
|
42
|
+
}
|
|
43
|
+
upsertByKey(input) {
|
|
44
|
+
const payload = this.readPayload();
|
|
45
|
+
const threadId = input.threadId || "pending";
|
|
46
|
+
const messages = payload.messagesByThread[threadId] ?? [];
|
|
47
|
+
const now = new Date().toISOString();
|
|
48
|
+
const existing = messages.find((message) => message.key === input.key);
|
|
49
|
+
if (existing) {
|
|
50
|
+
existing.role = input.role;
|
|
51
|
+
existing.text = input.text;
|
|
52
|
+
existing.source = input.source;
|
|
53
|
+
existing.turnId = input.turnId;
|
|
54
|
+
existing.timestamp = input.timestamp ?? now;
|
|
55
|
+
existing.key = input.key;
|
|
56
|
+
this.store.write(payload);
|
|
57
|
+
return { message: existing, inserted: false, updated: true };
|
|
58
|
+
}
|
|
59
|
+
const message = {
|
|
60
|
+
id: randomId(),
|
|
61
|
+
timestamp: input.timestamp ?? now,
|
|
62
|
+
...input,
|
|
63
|
+
threadId,
|
|
64
|
+
};
|
|
65
|
+
messages.push(message);
|
|
66
|
+
if (messages.length > this.maxMessages) {
|
|
67
|
+
messages.splice(0, messages.length - this.maxMessages);
|
|
68
|
+
}
|
|
69
|
+
payload.messagesByThread[threadId] = messages;
|
|
70
|
+
this.store.write(payload);
|
|
71
|
+
return { message, inserted: true, updated: false };
|
|
35
72
|
}
|
|
36
73
|
list(threadId, limit = 200) {
|
|
37
74
|
const messages = this.readPayload().messagesByThread[threadId || "pending"] ?? [];
|
|
@@ -53,7 +90,7 @@ export class WebChatStore {
|
|
|
53
90
|
const messagesByThread = {};
|
|
54
91
|
for (const [threadId, messages] of Object.entries(payload.messagesByThread)) {
|
|
55
92
|
if (Array.isArray(messages)) {
|
|
56
|
-
messagesByThread[threadId] = messages.filter(isWebChatMessage).slice(-this.maxMessages);
|
|
93
|
+
messagesByThread[threadId] = dedupeWebChatMessages(messages.filter(isWebChatMessage)).slice(-this.maxMessages);
|
|
57
94
|
}
|
|
58
95
|
}
|
|
59
96
|
return { version: 1, messagesByThread };
|
|
@@ -122,6 +159,7 @@ function isWebChatMessage(value) {
|
|
|
122
159
|
typeof candidate.threadId === "string" &&
|
|
123
160
|
typeof candidate.text === "string" &&
|
|
124
161
|
typeof candidate.timestamp === "string" &&
|
|
162
|
+
(candidate.key === undefined || typeof candidate.key === "string") &&
|
|
125
163
|
["user", "agent", "system", "tool"].includes(candidate.role) &&
|
|
126
164
|
["web", "telegram", "discord", "slack", "cli"].includes(candidate.source);
|
|
127
165
|
}
|
|
@@ -159,4 +197,35 @@ function activityActorMatches(actor, query) {
|
|
|
159
197
|
function randomId() {
|
|
160
198
|
return randomUUID().replace(/-/g, "").slice(0, 12);
|
|
161
199
|
}
|
|
162
|
-
|
|
200
|
+
function dedupeWebChatMessages(messages) {
|
|
201
|
+
const seen = new Set();
|
|
202
|
+
return messages.filter((message) => {
|
|
203
|
+
const key = webChatDedupKey(message);
|
|
204
|
+
if (!key) {
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
if (seen.has(key)) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
seen.add(key);
|
|
211
|
+
return true;
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
function findDuplicateWebChatMessage(messages, input) {
|
|
215
|
+
const key = webChatDedupKey(input);
|
|
216
|
+
return key ? messages.find((message) => webChatDedupKey(message) === key) : undefined;
|
|
217
|
+
}
|
|
218
|
+
function webChatDedupKey(message) {
|
|
219
|
+
const threadId = message.threadId || "pending";
|
|
220
|
+
if (message.key) {
|
|
221
|
+
return [threadId, message.key].join("\0");
|
|
222
|
+
}
|
|
223
|
+
if (message.turnId) {
|
|
224
|
+
return [threadId, message.role, message.source, message.turnId, message.text].join("\0");
|
|
225
|
+
}
|
|
226
|
+
if (message.timestamp) {
|
|
227
|
+
return [threadId, message.role, message.source, message.timestamp, message.text].join("\0");
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
export { activityActorLabel, activityCategoryForType, auditCategoryForAction, } from "../core/activity-events.js";
|