@nordbyte/nordrelay 0.2.1 → 0.3.0

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.
@@ -1,17 +1,21 @@
1
- import path from "node:path";
2
1
  import { createAgentSessionService } from "./agent-factory.js";
3
2
  import { CODEX_AGENT_CAPABILITIES } from "./agent.js";
4
3
  import { findLaunchProfile } from "./codex-launch.js";
5
- import { readJsonFileWithBackup, writeJsonFileAtomic } from "./persistence.js";
4
+ import { createDocumentStore } from "./state-backend.js";
6
5
  export class SessionRegistry {
7
6
  config;
8
7
  sessions = new Map();
9
8
  metadata = new Map();
10
- persistPath;
9
+ store;
11
10
  onRemoveCallback;
12
11
  constructor(config) {
13
12
  this.config = config;
14
- this.persistPath = path.join(config.workspace, ".nordrelay", "contexts.json");
13
+ this.store = createDocumentStore({
14
+ workspace: config.workspace,
15
+ fileName: "contexts.json",
16
+ sqliteKey: "contexts",
17
+ backend: config.stateBackend,
18
+ });
15
19
  this.loadPersistedMetadata();
16
20
  }
17
21
  async getOrCreate(contextKey, options) {
@@ -150,7 +154,7 @@ export class SessionRegistry {
150
154
  persistMetadata() {
151
155
  try {
152
156
  const data = [...this.metadata.values()];
153
- writeJsonFileAtomic(this.persistPath, data);
157
+ this.store.write(data);
154
158
  }
155
159
  catch (error) {
156
160
  console.warn("Failed to persist context metadata:", error instanceof Error ? error.message : String(error));
@@ -158,7 +162,7 @@ export class SessionRegistry {
158
162
  }
159
163
  loadPersistedMetadata() {
160
164
  try {
161
- const data = readJsonFileWithBackup(this.persistPath).value;
165
+ const data = this.store.read();
162
166
  if (!Array.isArray(data)) {
163
167
  return;
164
168
  }
@@ -0,0 +1,230 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ const SECRET_KEYS = new Set([
5
+ "TELEGRAM_BOT_TOKEN",
6
+ "CODEX_API_KEY",
7
+ "OPENAI_API_KEY",
8
+ "TELEGRAM_WEBHOOK_SECRET",
9
+ "NORDRELAY_DASHBOARD_TOKEN",
10
+ "NORDRELAY_DASHBOARD_PASSWORD",
11
+ ]);
12
+ export const SETTING_DEFINITIONS = [
13
+ setting("TELEGRAM_BOT_TOKEN", "Telegram bot token", "Telegram", "secret", "BotFather token.", true),
14
+ setting("TELEGRAM_ADMIN_USER_IDS", "Telegram admin user IDs", "Telegram", "list", "Comma-separated Telegram users allowed to administer and use the bot.", true),
15
+ setting("TELEGRAM_ALLOWED_USER_IDS", "Allowed operator user IDs", "Telegram", "list", "Optional non-admin operators.", true),
16
+ setting("TELEGRAM_READONLY_USER_IDS", "Readonly user IDs", "Telegram", "list", "Users allowed to inspect but not mutate.", true),
17
+ setting("TELEGRAM_ALLOWED_CHAT_IDS", "Allowed chat IDs", "Telegram", "list", "Optional chat allowlist.", true),
18
+ setting("TELEGRAM_ALLOW_ANY_CHAT", "Allow any Telegram chat", "Telegram", "boolean", "Unsafe override; keep off for normal use.", true),
19
+ setting("TELEGRAM_ROLE_POLICIES_JSON", "Role policy JSON", "Telegram", "json", "Granular Telegram permission policy.", true),
20
+ setting("TELEGRAM_TRANSPORT", "Telegram transport", "Telegram", "string", "polling or webhook.", true),
21
+ setting("TELEGRAM_WEBHOOK_URL", "Webhook public URL", "Telegram", "string", "Public base URL for webhook mode.", true),
22
+ setting("TELEGRAM_WEBHOOK_HOST", "Webhook bind host", "Telegram", "string", "Local webhook bind host.", true),
23
+ setting("TELEGRAM_WEBHOOK_PORT", "Webhook bind port", "Telegram", "number", "Local webhook bind port.", true),
24
+ setting("TELEGRAM_WEBHOOK_PATH", "Webhook path", "Telegram", "string", "Webhook request path.", true),
25
+ setting("TELEGRAM_WEBHOOK_SECRET", "Webhook secret", "Telegram", "secret", "Optional Telegram webhook secret token.", true),
26
+ setting("NORDRELAY_CODEX_ENABLED", "Enable Codex", "Agents", "boolean", "Allow Codex sessions.", true),
27
+ setting("NORDRELAY_PI_ENABLED", "Enable Pi", "Agents", "boolean", "Allow Pi sessions.", true),
28
+ setting("NORDRELAY_DEFAULT_AGENT", "Default agent", "Agents", "string", "codex or pi.", true),
29
+ setting("CODEX_API_KEY", "Codex API key", "Codex", "secret", "Optional Codex SDK API key.", true),
30
+ setting("CODEX_CLI_PATH", "Codex CLI path", "Codex", "string", "Optional explicit Codex executable path.", true),
31
+ setting("CODEX_USE_BUNDLED_CLI", "Use bundled Codex CLI", "Codex", "boolean", "Force SDK-bundled CLI instead of host CLI.", true),
32
+ setting("CODEX_MODEL", "Default Codex model", "Codex", "string", "Default model for new Codex threads.", false),
33
+ setting("CODEX_SYNC_INTERVAL_MS", "Codex sync interval", "Codex", "number", "Local state sync interval.", true),
34
+ setting("CODEX_EXTERNAL_BUSY_CHECK_MS", "External busy check", "Codex", "number", "External CLI busy polling interval.", true),
35
+ setting("CODEX_EXTERNAL_BUSY_STALE_MS", "External busy stale timeout", "Codex", "number", "External CLI stale timeout.", true),
36
+ setting("CODEX_SANDBOX_MODE", "Codex sandbox mode", "Codex", "string", "read-only, workspace-write, or danger-full-access.", true),
37
+ setting("CODEX_APPROVAL_POLICY", "Codex approval policy", "Codex", "string", "never, on-request, on-failure, or untrusted.", true),
38
+ setting("CODEX_LAUNCH_PROFILES_JSON", "Launch profiles JSON", "Codex", "json", "Additional launch profile definitions.", true),
39
+ setting("CODEX_DEFAULT_LAUNCH_PROFILE", "Default launch profile", "Codex", "string", "Launch profile ID used by default.", true),
40
+ setting("ENABLE_UNSAFE_LAUNCH_PROFILES", "Enable unsafe profiles", "Codex", "boolean", "Expose danger-full-access profiles.", true),
41
+ setting("PI_CLI_PATH", "Pi CLI path", "Pi", "string", "Optional Pi executable path.", true),
42
+ setting("PI_SESSION_DIR", "Pi session dir", "Pi", "string", "Optional Pi session directory.", true),
43
+ setting("PI_DEFAULT_MODEL", "Default Pi model", "Pi", "string", "Default Pi model slug.", false),
44
+ setting("PI_DEFAULT_THINKING", "Default Pi thinking", "Pi", "string", "off, minimal, low, medium, high, or xhigh.", false),
45
+ setting("CONNECTOR_LOG_FORMAT", "Log format", "Operations", "string", "text or json.", true),
46
+ setting("TOOL_VERBOSITY", "Tool verbosity", "Operations", "string", "all, summary, errors-only, or none.", false),
47
+ setting("SHOW_TURN_TOKEN_USAGE", "Show turn token usage", "Operations", "boolean", "Append per-turn token usage.", false),
48
+ setting("ENABLE_TELEGRAM_LOGIN", "Enable Telegram login", "Operations", "boolean", "Allow /login and /logout.", true),
49
+ setting("ENABLE_TELEGRAM_REACTIONS", "Enable Telegram reactions", "Operations", "boolean", "Send Telegram reactions.", true),
50
+ setting("TELEGRAM_RATE_LIMIT_MIN_INTERVAL_MS", "Telegram send interval", "Operations", "number", "Minimum send interval.", true),
51
+ setting("TELEGRAM_EDIT_MIN_INTERVAL_MS", "Telegram edit interval", "Operations", "number", "Minimum edit interval.", true),
52
+ setting("TELEGRAM_CLI_MIRROR_MODE", "CLI mirror mode", "Operations", "string", "off, status, final, or full.", false),
53
+ setting("TELEGRAM_CLI_MIRROR_MIN_UPDATE_MS", "CLI mirror update interval", "Operations", "number", "Minimum mirrored edit interval.", true),
54
+ setting("TELEGRAM_NOTIFY_MODE", "Notify mode", "Operations", "string", "off, minimal, or all.", false),
55
+ setting("TELEGRAM_QUIET_HOURS", "Quiet hours", "Operations", "string", "HH-HH or blank.", false),
56
+ setting("TELEGRAM_REDACT_PATTERNS", "Redaction patterns", "Operations", "list", "Additional comma-separated regex patterns.", true),
57
+ setting("NORDRELAY_UPDATE_METHOD", "Update method", "Operations", "string", "auto, npm, or git.", true),
58
+ setting("MAX_FILE_SIZE", "Max file size", "Artifacts", "number", "Max inbound/outbound file size.", true),
59
+ setting("ARTIFACT_RETENTION_DAYS", "Artifact retention days", "Artifacts", "number", "Days before pruning.", true),
60
+ setting("ARTIFACT_MAX_TURNS", "Max artifact turns", "Artifacts", "number", "Maximum artifact turns retained.", true),
61
+ setting("ARTIFACT_MAX_INBOX_DIRS", "Max inbox dirs", "Artifacts", "number", "Maximum inbox dirs retained.", true),
62
+ setting("ARTIFACT_IGNORE_DIRS", "Artifact ignore dirs", "Artifacts", "list", "Extra ignored dirs or relative paths.", true),
63
+ setting("ARTIFACT_IGNORE_GLOBS", "Artifact ignore globs", "Artifacts", "list", "Extra ignored glob patterns.", true),
64
+ setting("TELEGRAM_AUTO_SEND_ARTIFACTS", "Auto-send artifacts", "Artifacts", "boolean", "Automatically send artifact files.", false),
65
+ setting("WORKSPACE_ALLOWED_ROOTS", "Workspace allowed roots", "Workspace", "list", "Restrict selectable workspaces.", true),
66
+ setting("WORKSPACE_WARN_ROOTS", "Workspace warn roots", "Workspace", "list", "Warn for broad workspace roots.", true),
67
+ setting("NORDRELAY_STATE_BACKEND", "State backend", "Workspace", "string", "json or sqlite.", true),
68
+ setting("NORDRELAY_AUDIT_MAX_EVENTS", "Audit max events", "Workspace", "number", "Retained audit events.", true),
69
+ setting("NORDRELAY_SESSION_LOCK_TTL_MS", "Session lock TTL", "Workspace", "number", "Write-lock TTL.", true),
70
+ setting("NORDRELAY_VERSION_CACHE_TTL_MS", "Version cache TTL", "Workspace", "number", "NPM version cache TTL.", true),
71
+ setting("OPENAI_API_KEY", "OpenAI API key", "Voice", "secret", "Whisper fallback API key.", true),
72
+ setting("VOICE_PREFERRED_BACKEND", "Voice backend", "Voice", "string", "auto, parakeet, faster-whisper, or openai.", false),
73
+ setting("VOICE_DEFAULT_LANGUAGE", "Voice language", "Voice", "string", "Default transcription language.", false),
74
+ setting("VOICE_TRANSCRIBE_ONLY", "Voice transcribe only", "Voice", "boolean", "Do not send voice transcripts as prompts.", false),
75
+ setting("FASTER_WHISPER_PYTHON", "faster-whisper Python", "Voice", "string", "Python executable.", true),
76
+ setting("FASTER_WHISPER_MODEL", "faster-whisper model", "Voice", "string", "Model name.", true),
77
+ setting("FASTER_WHISPER_DEVICE", "faster-whisper device", "Voice", "string", "cpu, cuda, etc.", true),
78
+ setting("FASTER_WHISPER_COMPUTE_TYPE", "faster-whisper compute type", "Voice", "string", "int8, float16, etc.", true),
79
+ setting("FASTER_WHISPER_LANGUAGE", "faster-whisper language", "Voice", "string", "Fixed transcription language.", true),
80
+ setting("FASTER_WHISPER_TIMEOUT_MS", "faster-whisper timeout", "Voice", "number", "Transcription timeout.", true),
81
+ setting("NORDRELAY_DASHBOARD_TOKEN", "Dashboard token", "Dashboard", "secret", "Bearer/login token for WebUI.", true),
82
+ setting("NORDRELAY_DASHBOARD_USER", "Dashboard user", "Dashboard", "string", "Optional Basic Auth user.", true),
83
+ setting("NORDRELAY_DASHBOARD_PASSWORD", "Dashboard password", "Dashboard", "secret", "Optional Basic Auth password.", true),
84
+ setting("NORDRELAY_DASHBOARD_HOST", "Dashboard host", "Dashboard", "string", "WebUI bind host.", true),
85
+ setting("NORDRELAY_DASHBOARD_PORT", "Dashboard port", "Dashboard", "number", "WebUI bind port.", true),
86
+ ];
87
+ export class SettingsService {
88
+ envPath;
89
+ constructor(envPath) {
90
+ this.envPath = envPath;
91
+ }
92
+ async snapshot(env = process.env) {
93
+ const parsed = await readEnvFile(this.envPath);
94
+ const settings = SETTING_DEFINITIONS.map((definition) => {
95
+ const configuredValue = parsed[definition.key];
96
+ const effectiveValue = configuredValue ?? env[definition.key] ?? "";
97
+ const masked = SECRET_KEYS.has(definition.key) && Boolean(effectiveValue);
98
+ return {
99
+ ...definition,
100
+ value: masked ? maskSecret(effectiveValue) : effectiveValue,
101
+ effectiveValue: masked ? maskSecret(effectiveValue) : effectiveValue,
102
+ configured: configuredValue !== undefined,
103
+ masked,
104
+ };
105
+ });
106
+ return { envPath: this.envPath, settings };
107
+ }
108
+ async update(patch) {
109
+ const current = await readEnvFile(this.envPath);
110
+ const changedKeys = [];
111
+ const definitions = new Map(SETTING_DEFINITIONS.map((definition) => [definition.key, definition]));
112
+ for (const [key, rawValue] of Object.entries(patch)) {
113
+ const definition = definitions.get(key);
114
+ if (!definition) {
115
+ continue;
116
+ }
117
+ const value = normalizeSettingValue(rawValue);
118
+ if (value === undefined || isMaskedSecret(value)) {
119
+ continue;
120
+ }
121
+ if (value === "") {
122
+ if (current[key] !== undefined) {
123
+ delete current[key];
124
+ changedKeys.push(key);
125
+ }
126
+ continue;
127
+ }
128
+ if (current[key] !== value) {
129
+ current[key] = value;
130
+ changedKeys.push(key);
131
+ }
132
+ }
133
+ if (changedKeys.length > 0) {
134
+ await writeEnvFile(this.envPath, current);
135
+ }
136
+ return {
137
+ envPath: this.envPath,
138
+ changedKeys,
139
+ restartRequired: changedKeys.some((key) => definitions.get(key)?.restartRequired),
140
+ };
141
+ }
142
+ }
143
+ export function resolveDashboardEnvPath(home, cwd = process.cwd()) {
144
+ if (process.env.NORDRELAY_ENV_FILE) {
145
+ return path.resolve(process.env.NORDRELAY_ENV_FILE);
146
+ }
147
+ const homeEnv = path.join(home, "nordrelay.env");
148
+ if (existsSync(homeEnv)) {
149
+ return homeEnv;
150
+ }
151
+ return path.join(cwd, ".env");
152
+ }
153
+ export function maskSecret(value) {
154
+ if (!value) {
155
+ return "";
156
+ }
157
+ if (value.length <= 8) {
158
+ return "********";
159
+ }
160
+ return `${value.slice(0, 4)}...${value.slice(-4)}`;
161
+ }
162
+ function setting(key, label, group, kind, description, restartRequired) {
163
+ return { key, label, group, kind, description, restartRequired };
164
+ }
165
+ async function readEnvFile(filePath) {
166
+ try {
167
+ return parseEnvText(await readFile(filePath, "utf8"));
168
+ }
169
+ catch {
170
+ return {};
171
+ }
172
+ }
173
+ function parseEnvText(text) {
174
+ const result = {};
175
+ for (const rawLine of text.split(/\r?\n/)) {
176
+ const line = rawLine.trim();
177
+ if (!line || line.startsWith("#")) {
178
+ continue;
179
+ }
180
+ const normalized = line.startsWith("export ") ? line.slice(7).trim() : line;
181
+ const equals = normalized.indexOf("=");
182
+ if (equals < 1) {
183
+ continue;
184
+ }
185
+ const key = normalized.slice(0, equals).trim();
186
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
187
+ continue;
188
+ }
189
+ result[key] = unquoteEnvValue(normalized.slice(equals + 1).trim());
190
+ }
191
+ return result;
192
+ }
193
+ async function writeEnvFile(filePath, values) {
194
+ await mkdir(path.dirname(filePath), { recursive: true });
195
+ const orderedKeys = [
196
+ ...SETTING_DEFINITIONS.map((definition) => definition.key).filter((key) => values[key] !== undefined),
197
+ ...Object.keys(values).filter((key) => !SETTING_DEFINITIONS.some((definition) => definition.key === key)).sort(),
198
+ ];
199
+ const lines = [
200
+ "# NordRelay runtime config managed by the dashboard.",
201
+ ...orderedKeys.map((key) => `${key}=${quoteEnvValue(values[key] ?? "")}`),
202
+ "",
203
+ ];
204
+ await writeFile(filePath, lines.join("\n"), { mode: 0o600 });
205
+ }
206
+ function unquoteEnvValue(value) {
207
+ if ((value.startsWith('"') && value.endsWith('"')) ||
208
+ (value.startsWith("'") && value.endsWith("'"))) {
209
+ return value.slice(1, -1).replace(/\\n/g, "\n");
210
+ }
211
+ return value;
212
+ }
213
+ function quoteEnvValue(value) {
214
+ if (!value) {
215
+ return "";
216
+ }
217
+ if (/^[A-Za-z0-9_./:@,+-]+$/.test(value)) {
218
+ return value;
219
+ }
220
+ return JSON.stringify(value);
221
+ }
222
+ function normalizeSettingValue(value) {
223
+ if (value === undefined || value === null) {
224
+ return "";
225
+ }
226
+ return String(value).trim();
227
+ }
228
+ function isMaskedSecret(value) {
229
+ return value === "********" || /^\*+$/.test(value) || /^[^*]{1,4}\.\.\.[^*]{1,4}$/.test(value);
230
+ }
@@ -0,0 +1,74 @@
1
+ import { createRequire } from "node:module";
2
+ import path from "node:path";
3
+ import { readJsonFileWithBackup, writeJsonFileAtomic } from "./persistence.js";
4
+ const require = createRequire(import.meta.url);
5
+ export function createDocumentStore(options) {
6
+ if (options.backend === "sqlite") {
7
+ const sqlite = tryCreateSqliteDocumentStore(options);
8
+ if (sqlite) {
9
+ return sqlite;
10
+ }
11
+ console.warn("SQLite state backend is not available. Falling back to JSON files.");
12
+ }
13
+ return createJsonDocumentStore(options);
14
+ }
15
+ export function stateBackendPath(workspace, backend, fileName) {
16
+ if (backend === "sqlite") {
17
+ return path.join(workspace, ".nordrelay", "state.sqlite");
18
+ }
19
+ return path.join(workspace, ".nordrelay", fileName ?? "state.json");
20
+ }
21
+ function createJsonDocumentStore(options) {
22
+ const filePath = stateBackendPath(options.workspace, "json", options.fileName);
23
+ return {
24
+ kind: "json",
25
+ filePath,
26
+ read() {
27
+ return readJsonFileWithBackup(filePath).value;
28
+ },
29
+ write(value) {
30
+ writeJsonFileAtomic(filePath, value);
31
+ },
32
+ };
33
+ }
34
+ function tryCreateSqliteDocumentStore(options) {
35
+ let Database;
36
+ try {
37
+ Database = require("better-sqlite3");
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ const filePath = stateBackendPath(options.workspace, "sqlite");
43
+ const db = new Database(filePath);
44
+ db.exec([
45
+ "CREATE TABLE IF NOT EXISTS documents (",
46
+ "key TEXT PRIMARY KEY,",
47
+ "json TEXT NOT NULL,",
48
+ "updated_at TEXT NOT NULL",
49
+ ")",
50
+ ].join(" "));
51
+ return {
52
+ kind: "sqlite",
53
+ filePath,
54
+ read() {
55
+ const row = db.prepare("SELECT json FROM documents WHERE key = ?").get(options.sqliteKey);
56
+ if (typeof row?.json !== "string") {
57
+ return undefined;
58
+ }
59
+ try {
60
+ return JSON.parse(row.json);
61
+ }
62
+ catch (error) {
63
+ console.warn(`Failed to parse SQLite state document ${options.sqliteKey}:`, error instanceof Error ? error.message : String(error));
64
+ return undefined;
65
+ }
66
+ },
67
+ write(value) {
68
+ db.prepare([
69
+ "INSERT INTO documents (key, json, updated_at) VALUES (?, ?, ?)",
70
+ "ON CONFLICT(key) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at",
71
+ ].join(" ")).run(options.sqliteKey, JSON.stringify(value), new Date().toISOString());
72
+ },
73
+ };
74
+ }