routstrd 0.4.1 → 0.4.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "routstrd",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "module": "src/index.ts",
5
5
  "type": "module",
6
6
  "private": false,
@@ -26,7 +26,7 @@
26
26
  "@cashu/cashu-ts": "^4.3.0",
27
27
  "@cashu/coco-core": "^1.0.1",
28
28
  "@cashu/coco-sqlite-bun": "^1.0.1",
29
- "@routstr/sdk": "^0.4.0",
29
+ "@routstr/sdk": "^0.4.1",
30
30
  "@scure/bip39": "^2.2.0",
31
31
  "applesauce-core": "^5.1.0",
32
32
  "applesauce-relay": "^5.1.0",
package/src/cli.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { program } from "commander";
2
2
  import { startDaemon } from "./start-daemon";
3
+ import { ensureDirsSync, saveDaemonConfig } from "./daemon/config-store";
3
4
  import {
4
5
  handleDaemonCommand,
5
6
  callDaemon,
@@ -217,19 +218,19 @@ async function requireLocalDaemon(): Promise<void> {
217
218
  async function initDaemon(): Promise<void> {
218
219
  console.log("Initializing routstrd...");
219
220
 
220
- // Create config directory
221
+ // Create config directory (0700, correcting existing installs too)
221
222
  if (!existsSync(CONFIG_DIR)) {
222
- mkdirSync(CONFIG_DIR, { recursive: true });
223
223
  console.log(`Created config directory: ${CONFIG_DIR}`);
224
224
  }
225
+ ensureDirsSync();
225
226
 
226
- // Create initial config
227
+ // Create initial config (0600, atomic write)
227
228
  if (!existsSync(CONFIG_FILE)) {
228
229
  const config: RoutstrdConfig = {
229
230
  ...DEFAULT_CONFIG,
230
231
  cocodPath: null,
231
232
  };
232
- await Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2));
233
+ saveDaemonConfig(config);
233
234
  console.log(`Created config file: ${CONFIG_FILE}`);
234
235
  }
235
236
 
@@ -240,7 +241,7 @@ async function initDaemon(): Promise<void> {
240
241
  const nsec = nip19.nsecEncode(secretKey);
241
242
  const npub = npubFromSecretKey(secretKey);
242
243
  config.nsec = nsec;
243
- await Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2));
244
+ saveDaemonConfig(config);
244
245
  console.log("\nA new Nostr identity has been generated for authentication.");
245
246
  console.log(`Your npub: ${npub}`);
246
247
  console.log(`You can view it in the config file at: ${CONFIG_FILE}\n`);
@@ -295,10 +296,7 @@ program
295
296
  .command("update")
296
297
  .description("Update routstrd to the latest version")
297
298
  .action(async () => {
298
- const packages = [
299
- { name: "routstrd", label: "routstrd" },
300
- { name: "@routstr/cocod", label: "cocod" },
301
- ];
299
+ const packages = [{ name: "routstrd", label: "routstrd" }];
302
300
 
303
301
  let updatedAny = false;
304
302
 
@@ -520,7 +518,7 @@ program
520
518
  ...updates,
521
519
  };
522
520
 
523
- await Bun.write(CONFIG_FILE, JSON.stringify(updatedConfig, null, 2));
521
+ saveDaemonConfig(updatedConfig);
524
522
 
525
523
  console.log(`Remote daemon URL set to: ${url}`);
526
524
  if (options.authUrl) {
@@ -555,7 +553,7 @@ program
555
553
  const { daemonUrl: _daemonUrl, authUrl: _authUrl, ...rest } = config;
556
554
  const updatedConfig: RoutstrdConfig = rest;
557
555
 
558
- await Bun.write(CONFIG_FILE, JSON.stringify(updatedConfig, null, 2));
556
+ saveDaemonConfig(updatedConfig);
559
557
 
560
558
  console.log("Switched back to local daemon mode.");
561
559
  if (previousDaemonUrl) {
@@ -1119,6 +1117,94 @@ providersCmd
1119
1117
  }
1120
1118
  });
1121
1119
 
1120
+ providersCmd
1121
+ .command("reviews")
1122
+ .description(
1123
+ "Show all known providers with their stored review events and event IDs",
1124
+ )
1125
+ .action(async () => {
1126
+ await ensureDaemonRunning();
1127
+
1128
+ const result = await callDaemon("/providers/reviews");
1129
+ if (result.error) {
1130
+ console.log(result.error);
1131
+ process.exit(1);
1132
+ }
1133
+
1134
+ const output = result.output as
1135
+ | {
1136
+ providers: Array<{
1137
+ index: number;
1138
+ baseUrl: string;
1139
+ disabled: boolean;
1140
+ nodePubkeys: string[];
1141
+ reviewCount: number;
1142
+ reviews: Array<{
1143
+ eventId: string;
1144
+ createdAt: number;
1145
+ authorPubkey: string | null;
1146
+ nodePubkey: string | null;
1147
+ label: string;
1148
+ isLgtm: boolean;
1149
+ tags: string[][];
1150
+ }>;
1151
+ }>;
1152
+ unmatchedReviews: Array<{
1153
+ eventId: string;
1154
+ createdAt: number;
1155
+ authorPubkey: string | null;
1156
+ nodePubkey: string | null;
1157
+ label: string;
1158
+ isLgtm: boolean;
1159
+ tags: string[][];
1160
+ }>;
1161
+ totalCount: number;
1162
+ reviewEventCount: number;
1163
+ }
1164
+ | undefined;
1165
+
1166
+ if (!output) {
1167
+ console.log("No provider review data available.");
1168
+ return;
1169
+ }
1170
+
1171
+ console.log(
1172
+ `Providers (${output.totalCount} total, ${output.reviewEventCount} stored review events):\n`,
1173
+ );
1174
+
1175
+ for (const provider of output.providers) {
1176
+ const status = provider.disabled ? "DISABLED" : "enabled ";
1177
+ console.log(
1178
+ ` [${provider.index}] ${status} ${provider.baseUrl} (${provider.reviewCount} review${provider.reviewCount === 1 ? "" : "s"})`,
1179
+ );
1180
+
1181
+ if (provider.nodePubkeys.length > 0) {
1182
+ console.log(` node pubkeys: ${provider.nodePubkeys.join(", ")}`);
1183
+ }
1184
+
1185
+ for (const review of provider.reviews) {
1186
+ const label = review.label || "review";
1187
+ const created = new Date(review.createdAt * 1000).toISOString();
1188
+ console.log(` - ${label} eventId: ${review.eventId}`);
1189
+ console.log(` node: ${review.nodePubkey ?? "(none)"}`);
1190
+ console.log(` author: ${review.authorPubkey ?? "(none)"}`);
1191
+ console.log(` at: ${created}`);
1192
+ }
1193
+ }
1194
+
1195
+ if (output.unmatchedReviews.length > 0) {
1196
+ console.log(
1197
+ `\n Unmatched review events (${output.unmatchedReviews.length} — node pubkey not in any known provider):`,
1198
+ );
1199
+ for (const review of output.unmatchedReviews) {
1200
+ const created = new Date(review.createdAt * 1000).toISOString();
1201
+ console.log(` - eventId: ${review.eventId}`);
1202
+ console.log(` node: ${review.nodePubkey ?? "(none)"}`);
1203
+ console.log(` at: ${created}`);
1204
+ }
1205
+ }
1206
+ });
1207
+
1122
1208
  // Clients - list and manage clients
1123
1209
  const clientsCmd = program
1124
1210
  .command("clients")
@@ -2142,7 +2228,7 @@ program
2142
2228
  ...config,
2143
2229
  mode: selectedMode,
2144
2230
  };
2145
- await Bun.write(CONFIG_FILE, JSON.stringify(updatedConfig, null, 2));
2231
+ saveDaemonConfig(updatedConfig);
2146
2232
  console.log(`Mode set to '${selectedMode}'. Restarting daemon...`);
2147
2233
 
2148
2234
  // Restart daemon
@@ -1,5 +1,13 @@
1
1
  import { mkdir } from "fs/promises";
2
- import { existsSync, readFileSync } from "fs";
2
+ import {
3
+ chmodSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ renameSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from "fs";
3
11
  import {
4
12
  CONFIG_DIR,
5
13
  CONFIG_FILE,
@@ -10,39 +18,119 @@ import { logger } from "../utils/logger";
10
18
 
11
19
  export const REQUESTS_DIR = `${CONFIG_DIR}/requests`;
12
20
 
21
+ // The daemon config holds spend-capable credentials (the operator `nsec` and
22
+ // the NWC connection string), so the directory and file permissions must match
23
+ // the wallet seed handling (0700/0600), not the umask default (0755/0644).
24
+
25
+ function chmodIgnoreErrors(path: string, mode: number): void {
26
+ try {
27
+ chmodSync(path, mode);
28
+ } catch {
29
+ // Best effort on filesystems that do not support POSIX modes.
30
+ }
31
+ }
32
+
33
+ /** Create the config directories (0700), correcting existing installs too. */
34
+ export function ensureDirsSync(): void {
35
+ try {
36
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
37
+ } catch {
38
+ // Directory may already exist
39
+ }
40
+ chmodSync(CONFIG_DIR, 0o700);
41
+
42
+ try {
43
+ mkdirSync(REQUESTS_DIR, { recursive: true, mode: 0o700 });
44
+ } catch {
45
+ // Directory may already exist
46
+ }
47
+ chmodSync(REQUESTS_DIR, 0o700);
48
+ }
49
+
13
50
  export async function ensureDirs(): Promise<void> {
14
51
  try {
15
- await mkdir(CONFIG_DIR, { recursive: true });
16
- await mkdir(REQUESTS_DIR, { recursive: true });
52
+ await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
53
+ } catch {
54
+ // Directory may already exist
55
+ }
56
+ chmodSync(CONFIG_DIR, 0o700);
57
+
58
+ try {
59
+ await mkdir(REQUESTS_DIR, { recursive: true, mode: 0o700 });
17
60
  } catch {
18
61
  // Directory may already exist
19
62
  }
63
+ chmodSync(REQUESTS_DIR, 0o700);
64
+ }
65
+
66
+ function repairConfigPermissions(): void {
67
+ if (existsSync(CONFIG_FILE)) {
68
+ chmodIgnoreErrors(CONFIG_FILE, 0o600);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Environment-variable overrides take precedence over config.json so operators
74
+ * can set values via Cloudron/docker env (e.g. ROUTSTRD_MODELS_PUBKEY) without
75
+ * hand-editing the config file.
76
+ */
77
+ function envConfigOverrides(): Partial<RoutstrdConfig> {
78
+ const overrides: Partial<RoutstrdConfig> = {};
79
+ if (process.env.ROUTSTRD_MODELS_PUBKEY) {
80
+ overrides.routstrModelsPubkey = process.env.ROUTSTRD_MODELS_PUBKEY;
81
+ }
82
+ return overrides;
20
83
  }
21
84
 
22
85
  export async function loadDaemonConfig(): Promise<RoutstrdConfig> {
23
86
  try {
24
87
  if (existsSync(CONFIG_FILE)) {
88
+ repairConfigPermissions();
25
89
  const content = await Bun.file(CONFIG_FILE).text();
26
- return { ...DEFAULT_CONFIG, ...JSON.parse(content) };
90
+ return { ...DEFAULT_CONFIG, ...JSON.parse(content), ...envConfigOverrides() };
27
91
  }
28
92
  } catch (error) {
29
93
  logger.error("Failed to load config:", error);
30
94
  }
31
- return DEFAULT_CONFIG;
95
+ return { ...DEFAULT_CONFIG, ...envConfigOverrides() };
32
96
  }
33
97
 
34
98
  export function loadDaemonConfigSync(): RoutstrdConfig {
35
99
  try {
36
100
  if (existsSync(CONFIG_FILE)) {
101
+ repairConfigPermissions();
37
102
  const content = readFileSync(CONFIG_FILE, "utf-8");
38
- return { ...DEFAULT_CONFIG, ...JSON.parse(content) };
103
+ return { ...DEFAULT_CONFIG, ...JSON.parse(content), ...envConfigOverrides() };
39
104
  }
40
105
  } catch (error) {
41
106
  logger.error("Failed to load config:", error);
42
107
  }
43
- return DEFAULT_CONFIG;
108
+ return { ...DEFAULT_CONFIG, ...envConfigOverrides() };
44
109
  }
45
110
 
111
+ /**
112
+ * Persist the daemon config atomically with owner-only permissions, mirroring
113
+ * the wallet config writer (saveConfig in wallet/coco-client.ts): write a
114
+ * 0600 temp file, then rename over the target. Synchronous so callers get
115
+ * error propagation instead of silently dropping credential updates.
116
+ */
46
117
  export function saveDaemonConfig(config: RoutstrdConfig): void {
47
- Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2));
118
+ ensureDirsSync();
119
+ const temporaryFile = `${CONFIG_FILE}.${process.pid}.tmp`;
120
+ try {
121
+ writeFileSync(temporaryFile, JSON.stringify(config, null, 2), {
122
+ mode: 0o600,
123
+ });
124
+ renameSync(temporaryFile, CONFIG_FILE);
125
+ } catch (error) {
126
+ try {
127
+ unlinkSync(temporaryFile);
128
+ } catch {
129
+ // The temporary file may not have been created.
130
+ }
131
+ throw error;
132
+ }
133
+ // renameSync preserves the temp file's mode, but chmod anyway so an
134
+ // existing over-permissive file is corrected even on exotic filesystems.
135
+ chmodIgnoreErrors(CONFIG_FILE, 0o600);
48
136
  }
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import type { SdkStore } from "@routstr/sdk";
3
+ import { createModelService } from "./models";
4
+
5
+ /**
6
+ * `ensureProvidersBootstrapped` must mirror the review-disabled provider set
7
+ * (kind 38425) into the SdkStore. The SDK applies review disables to the
8
+ * discovery adapter during bootstrap, but `providers list` and per-model
9
+ * provider views read the store — if the store is not kept in sync, a fresh
10
+ * install reports "0 disabled" while routing silently excludes them.
11
+ */
12
+ describe("createModelService.ensureProvidersBootstrapped", () => {
13
+ function makeStore(initialBaseUrls: string[] = []) {
14
+ const disabledCalls: string[][] = [];
15
+ const state: Record<string, unknown> = {
16
+ baseUrlsList: initialBaseUrls,
17
+ setBaseUrlsList: (urls: string[]) => {
18
+ state.baseUrlsList = urls;
19
+ },
20
+ disabledProviders: [],
21
+ setDisabledProviders: (urls: string[]) => {
22
+ state.disabledProviders = urls;
23
+ disabledCalls.push(urls);
24
+ },
25
+ manuallyDisabledProviders: [],
26
+ manuallyEnabledProviders: [],
27
+ };
28
+
29
+ return {
30
+ store: { getState: () => state } as unknown as SdkStore,
31
+ state,
32
+ disabledCalls,
33
+ };
34
+ }
35
+
36
+ function makeModelManager(
37
+ providers: string[],
38
+ reviewedDisabled: string[] | null,
39
+ ) {
40
+ return {
41
+ bootstrapProviders: async () => providers,
42
+ syncReviewedProvidersFromNostr: async () => reviewedDisabled,
43
+ } as never;
44
+ }
45
+
46
+ it("mirrors review-disabled providers into the store after bootstrap", async () => {
47
+ const { store, state, disabledCalls } = makeStore();
48
+ const modelManager = makeModelManager(
49
+ ["https://ok.example/", "https://bad.example/"],
50
+ ["https://bad.example/"],
51
+ );
52
+ const service = createModelService(
53
+ modelManager as never,
54
+ {} as never,
55
+ store,
56
+ );
57
+
58
+ await service.ensureProvidersBootstrapped();
59
+
60
+ expect(disabledCalls).toEqual([["https://bad.example/"]]);
61
+ expect(state.disabledProviders).toEqual(["https://bad.example/"]);
62
+ });
63
+
64
+ it("leaves the store unchanged when review sync reports no change (null)", async () => {
65
+ const { store, state, disabledCalls } = makeStore(["https://ok.example/"]);
66
+ const modelManager = makeModelManager(
67
+ ["https://ok.example/", "https://new.example/"],
68
+ null,
69
+ );
70
+ const service = createModelService(
71
+ modelManager as never,
72
+ {} as never,
73
+ store,
74
+ );
75
+
76
+ await service.ensureProvidersBootstrapped();
77
+
78
+ expect(disabledCalls).toEqual([]);
79
+ expect(state.disabledProviders).toEqual([]);
80
+ });
81
+ });
@@ -89,7 +89,11 @@ export function createModelService(
89
89
 
90
90
  // Sync discovered providers into the store so `providers list` reflects
91
91
  // the same set that the model manager knows about.
92
- const { baseUrlsList, setBaseUrlsList } = store.getState();
92
+ const {
93
+ baseUrlsList,
94
+ setBaseUrlsList,
95
+ setDisabledProviders,
96
+ } = store.getState();
93
97
  const existing = new Set(baseUrlsList);
94
98
  const merged = [
95
99
  ...baseUrlsList,
@@ -102,6 +106,18 @@ export function createModelService(
102
106
  );
103
107
  }
104
108
 
109
+ // Mirror the review-disabled set (kind 38425) into the store. The SDK
110
+ // applies review disables to the discovery adapter during bootstrap,
111
+ // but `providers list` and the per-model provider views read the store,
112
+ // so without this a fresh install reports "0 disabled" while routing
113
+ // silently excludes the review-disabled providers.
114
+ const reviewedDisabled = await modelManager.syncReviewedProvidersFromNostr(
115
+ providers,
116
+ );
117
+ if (reviewedDisabled !== null) {
118
+ setDisabledProviders(reviewedDisabled);
119
+ }
120
+
105
121
  logger.log("Provider bootstrap complete.");
106
122
  })().catch((error) => {
107
123
  providerBootstrapPromise = null;
@@ -117,7 +133,7 @@ export function createModelService(
117
133
  ): Promise<ExposedModel[]> => {
118
134
  const routstr21ModelIds = Array.from(
119
135
  new Set(await modelManager.fetchRoutstr21Models(forceRefresh)),
120
- ).slice(0, 21);
136
+ );
121
137
 
122
138
  let discoveredModels: ExposedModel[];
123
139
  if (!forceRefresh) {
@@ -8,6 +8,10 @@ import { callDaemon, getDaemonBaseUrl } from "../utils/daemon-client";
8
8
  type PiModelEntry = {
9
9
  id: string;
10
10
  contextWindow?: number;
11
+ name?: string;
12
+ input?: string[];
13
+ // Preserve arbitrary manual fields (reasoning, thinkingLevelMap, compat, cost, etc.)
14
+ [key: string]: unknown;
11
15
  };
12
16
 
13
17
  type PiProviderConfig = {
@@ -61,15 +65,45 @@ export async function installPiIntegration(
61
65
  return;
62
66
  }
63
67
 
68
+ // Preserve any manually-added per-model fields (reasoning, thinkingLevelMap,
69
+ // compat, cost, maxTokens, etc.) across refreshes. Only `id` and `contextWindow`
70
+ // are managed by routstrd.
71
+ const existingModels = new Map<string, PiModelEntry>(
72
+ (piConfig.providers["routstr"]?.models ?? []).map((m) => [m.id, m]),
73
+ );
74
+
64
75
  const providerModels: PiModelEntry[] = models.map((model) => {
65
- const entry: PiModelEntry = { id: model.id };
76
+ const previous = existingModels.get(model.id);
77
+ const entry: PiModelEntry = previous ? { ...previous } : { id: model.id };
78
+
79
+ entry.id = model.id;
80
+
66
81
  if (model.context_length !== undefined && model.context_length > 0) {
67
82
  entry.contextWindow = model.context_length;
83
+ } else {
84
+ delete entry.contextWindow;
68
85
  }
86
+
87
+ if (model.name) {
88
+ entry.name = model.name;
89
+ } else {
90
+ delete entry.name;
91
+ }
92
+
93
+ // Map the daemon's input modalities to Pi's ["text", "image"] vocabulary.
94
+ const mods = model.architecture?.input_modalities ?? [];
95
+ const input: string[] = [];
96
+ if (mods.includes("text")) input.push("text");
97
+ if (mods.includes("image")) input.push("image");
98
+ entry.input = input;
99
+
69
100
  return entry;
70
101
  });
71
102
 
103
+ // Preserve provider-level manual fields (compat, headers, name, etc.); only update
104
+ // the routstrd-managed fields below.
72
105
  piConfig.providers["routstr"] = {
106
+ ...(piConfig.providers["routstr"] ?? {}),
73
107
  baseUrl,
74
108
  api: "openai-completions",
75
109
  apiKey,
@@ -18,6 +18,15 @@ export type RoutstrModel = {
18
18
  id: string;
19
19
  name?: string;
20
20
  context_length?: number;
21
+ architecture?: {
22
+ modality?: string;
23
+ input_modalities?: string[];
24
+ output_modalities?: string[];
25
+ };
26
+ top_provider?: {
27
+ context_length?: number;
28
+ max_completion_tokens?: number;
29
+ };
21
30
  };
22
31
 
23
32
  export type IntegrationFn = (
@@ -1,5 +1,10 @@
1
1
  import type { UsageTrackingEntry } from "../../daemon/types.ts";
2
- import { callDaemon, isDaemonRunning } from "../../utils/daemon-client.ts";
2
+ import {
3
+ callDaemon,
4
+ getDaemonBaseUrl,
5
+ isDaemonRunning,
6
+ loadConfig,
7
+ } from "../../utils/daemon-client.ts";
3
8
  import type { UsageStats, UsageSummary } from "./types.ts";
4
9
 
5
10
  export { isDaemonRunning };
@@ -21,12 +26,17 @@ export interface StatusInfo {
21
26
  daemon: string;
22
27
  wallet: string;
23
28
  mode: "xcashu" | "apikeys";
29
+ /** URL the TUI is connected through (local bind or remote daemonUrl). */
30
+ url?: string;
24
31
  error?: string;
25
32
  }
26
33
 
27
34
  export async function fetchStatus(): Promise<StatusInfo | null> {
28
35
  try {
29
- const result = await callDaemon("/status");
36
+ const [result, config] = await Promise.all([
37
+ callDaemon("/status"),
38
+ loadConfig(),
39
+ ]);
30
40
  if (result.error) return null;
31
41
 
32
42
  const output = result.output as {
@@ -40,6 +50,7 @@ export async function fetchStatus(): Promise<StatusInfo | null> {
40
50
  daemon: output?.daemon || "unknown",
41
51
  wallet: output?.wallet || "unknown",
42
52
  mode: output?.mode || "apikeys",
53
+ url: getDaemonBaseUrl(config),
43
54
  error: output?.error,
44
55
  };
45
56
  } catch {
@@ -162,6 +162,14 @@ export function renderOverview(stats: UsageStats, balance: BalanceInfo | null, s
162
162
  const walletColor = status.wallet === "connected" ? COLORS.green : COLORS.red;
163
163
  const modeColor = COLORS.cyan;
164
164
  statusLines.push(`${COLORS.bold}Daemon:${COLORS.reset} ${daemonColor}${status.daemon}${COLORS.reset}`);
165
+ if (status.url) {
166
+ const label = "Running on: ";
167
+ const maxUrlLen = Math.max(8, halfWidth2 - 4 - label.length);
168
+ const url = status.url.length > maxUrlLen
169
+ ? `${status.url.slice(0, Math.max(0, maxUrlLen - 1))}…`
170
+ : status.url;
171
+ statusLines.push(`${COLORS.bold}${label}${COLORS.reset} ${COLORS.cyan}${url}${COLORS.reset}`);
172
+ }
165
173
  statusLines.push(`${COLORS.bold}Wallet:${COLORS.reset} ${walletColor}${status.wallet}${COLORS.reset}`);
166
174
  statusLines.push(`${COLORS.bold}Mode:${COLORS.reset} ${modeColor}${status.mode}${COLORS.reset}`);
167
175
  if (status.error) {
@@ -48,8 +48,10 @@ export interface RoutstrdConfig {
48
48
  * Defaults to daemonUrl or localhost:{port} if not set. */
49
49
  authUrl?: string;
50
50
  nsec?: string;
51
- /** Nostr hex pubkey for routstr review/model events (kind 38425/38423). */
51
+ /** Nostr hex pubkey for routstr review/audit events (kind 38425). */
52
52
  routstrPubkey?: string;
53
+ /** Nostr hex pubkey for the routstr-21 model list only (kind 38423). Falls back to routstrPubkey. */
54
+ routstrModelsPubkey?: string;
53
55
  /** Nostr relay URLs for provider/model discovery (kinds 38421/38423/38425).
54
56
  * When unset, each method uses its own built-in defaults. */
55
57
  relays?: string[];
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { spawnSync } from "child_process";
3
+ import { mkdtempSync, rmSync } from "fs";
4
+ import { join } from "path";
5
+ import { tmpdir } from "os";
6
+
7
+ // These scenarios mutate process.env.ROUTSTRD_DIR before the config-store
8
+ // module is loaded, and other test files import the same module singleton
9
+ // first — so each scenario runs in an isolated bun subprocess where the env
10
+ // is guaranteed to be in place before module evaluation.
11
+
12
+ const SCENARIO = join(import.meta.dir, "config-store.scenario.ts");
13
+
14
+ function runScenario(name: string): { code: number; out: string } {
15
+ const dir = mkdtempSync(join(tmpdir(), `routstrd-cfg-${name}-`));
16
+ const res = spawnSync("bun", [SCENARIO, name], {
17
+ env: {
18
+ ...process.env,
19
+ NODE_ENV: "test",
20
+ ROUTSTRD_DIR: join(dir, "daemon"),
21
+ },
22
+ stdout: "pipe",
23
+ stderr: "pipe",
24
+ });
25
+ const out = `${res.stdout}${res.stderr}`;
26
+ rmSync(dir, { recursive: true, force: true });
27
+ // bun's spawnSync reports the exit code in `status` (exitCode is undefined).
28
+ return { code: res.status ?? res.exitCode ?? -1, out };
29
+ }
30
+
31
+ describe("daemon config store permissions", () => {
32
+ test("fresh install: config dir 0700, config file 0600, atomic write", () => {
33
+ const { code, out } = runScenario("fresh-install");
34
+ expect(out).toContain("SCENARIO-OK");
35
+ expect(code).toBe(0);
36
+ });
37
+
38
+ test("existing over-permissive install (0755/0644) is repaired", () => {
39
+ const { code, out } = runScenario("repair-perms");
40
+ expect(out).toContain("SCENARIO-OK");
41
+ expect(code).toBe(0);
42
+ });
43
+
44
+ test("write failure surfaces synchronously with no temp file left", () => {
45
+ const { code, out } = runScenario("write-failure");
46
+ expect(out).toContain("SCENARIO-OK");
47
+ expect(code).toBe(0);
48
+ });
49
+
50
+ test("corrupt JSON falls back to defaults", () => {
51
+ const { code, out } = runScenario("corrupt-json");
52
+ expect(out).toContain("SCENARIO-OK");
53
+ expect(code).toBe(0);
54
+ });
55
+ });