mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-27 07:51:52 +00:00
Harden lock backend config validation and fallback behavior
This commit is contained in:
1 parent
9a64554d87
commit
84d3e7eded
7 files changed
+467
-293
No files matched your search
@@ -8,5 +8,7 @@ export const DEFAULT_RELAYS = [
|
|||||||
export const DEFAULT_TTL = 7200;
|
export const DEFAULT_TTL = 7200;
|
||||||
export const DEFAULT_NAMESPACE = 'torch';
|
export const DEFAULT_NAMESPACE = 'torch';
|
||||||
export const DEFAULT_QUERY_TIMEOUT_MS = 15_000;
|
export const DEFAULT_QUERY_TIMEOUT_MS = 15_000;
|
||||||
|
export const DEFAULT_PUBLISH_TIMEOUT_MS = 10_000;
|
||||||
|
export const DEFAULT_MIN_SUCCESSFUL_PUBLISHES = 1;
|
||||||
export const VALID_CADENCES = new Set(['daily', 'weekly']);
|
export const VALID_CADENCES = new Set(['daily', 'weekly']);
|
||||||
export const KIND_APP_DATA = 30078;
|
export const KIND_APP_DATA = 30078;
|
||||||
+24
-1
@@ -32,6 +32,9 @@ node bin/torch-lock.mjs list
|
|||||||
- `NOSTR_LOCK_RELAYS`
|
- `NOSTR_LOCK_RELAYS`
|
||||||
- `NOSTR_LOCK_TTL`
|
- `NOSTR_LOCK_TTL`
|
||||||
- `NOSTR_LOCK_QUERY_TIMEOUT_MS`
|
- `NOSTR_LOCK_QUERY_TIMEOUT_MS`
|
||||||
|
- `NOSTR_LOCK_PUBLISH_TIMEOUT_MS`
|
||||||
|
- `NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES`
|
||||||
|
- `NOSTR_LOCK_RELAY_FALLBACKS`
|
||||||
- `NOSTR_LOCK_DAILY_ROSTER`
|
- `NOSTR_LOCK_DAILY_ROSTER`
|
||||||
- `NOSTR_LOCK_WEEKLY_ROSTER`
|
- `NOSTR_LOCK_WEEKLY_ROSTER`
|
||||||
- `TORCH_CONFIG_PATH`
|
- `TORCH_CONFIG_PATH`
|
||||||
@@ -50,7 +53,10 @@ Common settings:
|
|||||||
- `nostrLock.namespace` — namespace prefix used in d-tags and hashtags.
|
- `nostrLock.namespace` — namespace prefix used in d-tags and hashtags.
|
||||||
- `nostrLock.relays` — relay list for check/lock/list operations.
|
- `nostrLock.relays` — relay list for check/lock/list operations.
|
||||||
- `nostrLock.ttlSeconds` — default lock TTL.
|
- `nostrLock.ttlSeconds` — default lock TTL.
|
||||||
- `nostrLock.queryTimeoutMs` — relay query timeout.
|
- `nostrLock.queryTimeoutMs` — relay query timeout (ms, valid range: 100..120000).
|
||||||
|
- `nostrLock.publishTimeoutMs` — per-relay publish timeout (ms, valid range: 100..120000).
|
||||||
|
- `nostrLock.minSuccessfulRelayPublishes` — minimum successful publishes required before lock acquisition continues (default: `1`).
|
||||||
|
- `nostrLock.relayFallbacks` — optional fallback relay URLs used when primary query/publish attempts fail quorum.
|
||||||
- `nostrLock.dailyRoster` / `nostrLock.weeklyRoster` — optional per-project roster overrides.
|
- `nostrLock.dailyRoster` / `nostrLock.weeklyRoster` — optional per-project roster overrides.
|
||||||
- `dashboard.defaultCadenceView` — default dashboard view (`daily`, `weekly`, `all`).
|
- `dashboard.defaultCadenceView` — default dashboard view (`daily`, `weekly`, `all`).
|
||||||
- `dashboard.defaultStatusView` — default dashboard status filter (`active`, `all`).
|
- `dashboard.defaultStatusView` — default dashboard status filter (`active`, `all`).
|
||||||
@@ -64,6 +70,23 @@ For weekly repository-fit maintenance, TORCH also includes `src/prompts/weekly/r
|
|||||||
|
|
||||||
Operational note: scheduler handoff commands are treated as required execution steps. A non-zero exit code (or missing command) is a hard failure: the scheduler writes a `_failed.md` task log, exits immediately, and does not publish `lock:complete` for that run.
|
Operational note: scheduler handoff commands are treated as required execution steps. A non-zero exit code (or missing command) is a hard failure: the scheduler writes a `_failed.md` task log, exits immediately, and does not publish `lock:complete` for that run.
|
||||||
|
|
||||||
|
|
||||||
|
## Lock backend production defaults
|
||||||
|
|
||||||
|
Recommended baseline for production scheduler runs:
|
||||||
|
|
||||||
|
- `nostrLock.relays`: 3+ geographically-diverse primary relays.
|
||||||
|
- `nostrLock.relayFallbacks`: 2 additional relays not present in primary list.
|
||||||
|
- `nostrLock.queryTimeoutMs`: `10000`
|
||||||
|
- `nostrLock.publishTimeoutMs`: `8000`
|
||||||
|
- `nostrLock.minSuccessfulRelayPublishes`: `2`
|
||||||
|
|
||||||
|
Validation behavior:
|
||||||
|
|
||||||
|
- Relay URLs must be absolute `ws://` or `wss://` URLs.
|
||||||
|
- Invalid relay URLs or invalid timeout/count ranges are fatal startup errors.
|
||||||
|
- Lock backend errors include phase (`query:primary`, `query:fallback`, `publish:primary`, `publish:fallback`), relay endpoint, and timeout value used.
|
||||||
|
|
||||||
## Scheduler lock reliability reporting
|
## Scheduler lock reliability reporting
|
||||||
|
|
||||||
Run the lock reliability summary to aggregate recent scheduler outcomes by platform, cadence, backend error category, and relay endpoint:
|
Run the lock reliability summary to aggregate recent scheduler outcomes by platform, cadence, backend error category, and relay endpoint:
|
||||||
|
|||||||
+128
-38
@@ -1,5 +1,10 @@
|
|||||||
import { SimplePool } from 'nostr-tools/pool';
|
import { SimplePool } from 'nostr-tools/pool';
|
||||||
import { getQueryTimeoutMs } from './torch-config.mjs';
|
import {
|
||||||
|
getQueryTimeoutMs,
|
||||||
|
getPublishTimeoutMs,
|
||||||
|
getMinSuccessfulRelayPublishes,
|
||||||
|
getRelayFallbacks,
|
||||||
|
} from './torch-config.mjs';
|
||||||
import { KIND_APP_DATA } from './constants.mjs';
|
import { KIND_APP_DATA } from './constants.mjs';
|
||||||
|
|
||||||
function nowUnix() {
|
function nowUnix() {
|
||||||
@@ -42,56 +47,141 @@ function filterActiveLocks(locks) {
|
|||||||
return locks.filter((lock) => !lock.expiresAt || lock.expiresAt > now);
|
return locks.filter((lock) => !lock.expiresAt || lock.expiresAt > now);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queryLocks(relays, cadence, dateStr, namespace) {
|
function withTimeout(promise, timeoutMs, timeoutMessage) {
|
||||||
const pool = new SimplePool();
|
|
||||||
const tagFilter = `${namespace}-lock-${cadence}-${dateStr}`;
|
|
||||||
const queryTimeoutMs = getQueryTimeoutMs();
|
|
||||||
|
|
||||||
let timeoutHandle;
|
let timeoutHandle;
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
timeoutHandle = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
|
||||||
|
});
|
||||||
|
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||||||
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function relayListLabel(relays) {
|
||||||
|
return relays.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryLocks(relays, cadence, dateStr, namespace, deps = {}) {
|
||||||
|
const {
|
||||||
|
poolFactory = () => new SimplePool(),
|
||||||
|
getQueryTimeoutMsFn = getQueryTimeoutMs,
|
||||||
|
getRelayFallbacksFn = getRelayFallbacks,
|
||||||
|
errorLogger = console.error,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
const pool = poolFactory();
|
||||||
|
const tagFilter = `${namespace}-lock-${cadence}-${dateStr}`;
|
||||||
|
const queryTimeoutMs = getQueryTimeoutMsFn();
|
||||||
|
const fallbackRelays = getRelayFallbacksFn();
|
||||||
|
|
||||||
|
const runQuery = async (relaySet, phase) => {
|
||||||
|
try {
|
||||||
|
const events = await withTimeout(
|
||||||
|
pool.querySync(relaySet, {
|
||||||
|
kinds: [KIND_APP_DATA],
|
||||||
|
'#t': [tagFilter],
|
||||||
|
}),
|
||||||
|
queryTimeoutMs,
|
||||||
|
`[${phase}] Relay query timed out after ${queryTimeoutMs}ms (relays: ${relayListLabel(relaySet)})`,
|
||||||
|
);
|
||||||
|
return filterActiveLocks(events.map(parseLockEvent));
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
throw new Error(
|
||||||
|
`[${phase}] Relay query failed (timeout=${queryTimeoutMs}ms, relays=${relayListLabel(relaySet)}): ${message}`,
|
||||||
|
{ cause: err },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const events = await Promise.race([
|
try {
|
||||||
pool.querySync(relays, {
|
return await runQuery(relays, 'query:primary');
|
||||||
kinds: [KIND_APP_DATA],
|
} catch (primaryErr) {
|
||||||
'#t': [tagFilter],
|
if (!fallbackRelays.length) {
|
||||||
}),
|
throw primaryErr;
|
||||||
new Promise((_, reject) => {
|
}
|
||||||
timeoutHandle = setTimeout(() => reject(new Error('Relay query timed out')), queryTimeoutMs);
|
errorLogger(`WARN: ${primaryErr.message}`);
|
||||||
}),
|
errorLogger(`WARN: retrying query with fallback relays (${relayListLabel(fallbackRelays)})`);
|
||||||
]);
|
return await runQuery(fallbackRelays, 'query:fallback');
|
||||||
|
|
||||||
return filterActiveLocks(events.map(parseLockEvent));
|
|
||||||
} finally {
|
|
||||||
if (timeoutHandle) {
|
|
||||||
clearTimeout(timeoutHandle);
|
|
||||||
}
|
}
|
||||||
pool.close(relays);
|
} finally {
|
||||||
|
const allRelays = [...new Set([...relays, ...fallbackRelays])];
|
||||||
|
pool.close(allRelays);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function publishLock(relays, event) {
|
async function publishToRelays(pool, relays, event, publishTimeoutMs, phase) {
|
||||||
const pool = new SimplePool();
|
const publishPromises = pool.publish(relays, event);
|
||||||
|
const settled = await Promise.allSettled(
|
||||||
|
publishPromises.map((publishPromise, index) => withTimeout(
|
||||||
|
publishPromise,
|
||||||
|
publishTimeoutMs,
|
||||||
|
`[${phase}] Publish timed out after ${publishTimeoutMs}ms (relay=${relays[index]})`,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return settled.map((result, index) => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
return { relay: relays[index], success: true, phase };
|
||||||
|
}
|
||||||
|
const reason = result.reason;
|
||||||
|
const message = reason instanceof Error ? reason.message : String(reason ?? 'unknown');
|
||||||
|
return { relay: relays[index], success: false, phase, message };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishLock(relays, event, deps = {}) {
|
||||||
|
const {
|
||||||
|
poolFactory = () => new SimplePool(),
|
||||||
|
getPublishTimeoutMsFn = getPublishTimeoutMs,
|
||||||
|
getMinSuccessfulRelayPublishesFn = getMinSuccessfulRelayPublishes,
|
||||||
|
getRelayFallbacksFn = getRelayFallbacks,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
const pool = poolFactory();
|
||||||
|
const publishTimeoutMs = getPublishTimeoutMsFn();
|
||||||
|
const minSuccesses = getMinSuccessfulRelayPublishesFn();
|
||||||
|
const fallbackRelays = getRelayFallbacksFn().filter((relay) => !relays.includes(relay));
|
||||||
|
|
||||||
|
const attempted = new Set();
|
||||||
|
const publishResults = [];
|
||||||
|
|
||||||
|
const attemptPhase = async (phaseRelays, phaseName) => {
|
||||||
|
if (!phaseRelays.length) return;
|
||||||
|
for (const relay of phaseRelays) {
|
||||||
|
attempted.add(relay);
|
||||||
|
}
|
||||||
|
const phaseResults = await publishToRelays(pool, phaseRelays, event, publishTimeoutMs, phaseName);
|
||||||
|
publishResults.push(...phaseResults);
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const publishPromises = pool.publish(relays, event);
|
await attemptPhase(relays, 'publish:primary');
|
||||||
const results = await Promise.allSettled(publishPromises);
|
|
||||||
const successes = results.filter((r) => r.status === 'fulfilled');
|
|
||||||
|
|
||||||
if (successes.length === 0) {
|
let successCount = publishResults.filter((result) => result.success).length;
|
||||||
const errors = results.map((r, i) => {
|
if (successCount < minSuccesses && fallbackRelays.length > 0) {
|
||||||
if (r.status === 'rejected') {
|
await attemptPhase(fallbackRelays, 'publish:fallback');
|
||||||
const reason = r.reason;
|
successCount = publishResults.filter((result) => result.success).length;
|
||||||
const message = reason instanceof Error ? reason.message : String(reason ?? 'unknown');
|
|
||||||
return `${relays[i]}: ${message}`;
|
|
||||||
}
|
|
||||||
return `${relays[i]}: unknown`;
|
|
||||||
});
|
|
||||||
throw new Error(`Failed to publish to any relay:\n ${errors.join('\n ')}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error(` Published to ${successes.length}/${relays.length} relays`);
|
if (successCount < minSuccesses) {
|
||||||
|
const failureLines = publishResults
|
||||||
|
.filter((result) => !result.success)
|
||||||
|
.map((result) => `${result.relay} (${result.phase}): ${result.message}`);
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Failed relay publish quorum in publish phase: ${successCount}/${attempted.size} successful ` +
|
||||||
|
`(required=${minSuccesses}, timeout=${publishTimeoutMs}ms)\n ${failureLines.join('\n ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(
|
||||||
|
` Published to ${successCount}/${attempted.size} relays ` +
|
||||||
|
`(required=${minSuccesses}, timeout=${publishTimeoutMs}ms)`,
|
||||||
|
);
|
||||||
return event;
|
return event;
|
||||||
} finally {
|
} finally {
|
||||||
pool.close(relays);
|
pool.close([...attempted]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+129
-7
@@ -5,11 +5,52 @@ import {
|
|||||||
DEFAULT_TTL,
|
DEFAULT_TTL,
|
||||||
DEFAULT_NAMESPACE,
|
DEFAULT_NAMESPACE,
|
||||||
DEFAULT_QUERY_TIMEOUT_MS,
|
DEFAULT_QUERY_TIMEOUT_MS,
|
||||||
|
DEFAULT_PUBLISH_TIMEOUT_MS,
|
||||||
|
DEFAULT_MIN_SUCCESSFUL_PUBLISHES,
|
||||||
} from './constants.mjs';
|
} from './constants.mjs';
|
||||||
|
|
||||||
const DEFAULT_CONFIG_PATH = 'torch-config.json';
|
const DEFAULT_CONFIG_PATH = 'torch-config.json';
|
||||||
|
|
||||||
let cachedConfig = null;
|
let cachedConfig = null;
|
||||||
|
const MIN_TIMEOUT_MS = 100;
|
||||||
|
const MAX_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
function parsePositiveInteger(value) {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return null;
|
||||||
|
return Math.floor(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRelayList(value) {
|
||||||
|
if (!Array.isArray(value)) return null;
|
||||||
|
const parsed = value.map((relay) => String(relay).trim()).filter(Boolean);
|
||||||
|
return parsed.length > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidRelayUrl(relay, sourceLabel) {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(relay);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Invalid relay URL in ${sourceLabel}: "${relay}" (must be an absolute ws:// or wss:// URL)`);
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') {
|
||||||
|
throw new Error(`Invalid relay URL in ${sourceLabel}: "${relay}" (protocol must be ws:// or wss://)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertTimeoutInRange(value, sourceLabel) {
|
||||||
|
if (!Number.isInteger(value) || value < MIN_TIMEOUT_MS || value > MAX_TIMEOUT_MS) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid ${sourceLabel}: ${value} (must be an integer between ${MIN_TIMEOUT_MS} and ${MAX_TIMEOUT_MS} ms)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPositiveCount(value, sourceLabel) {
|
||||||
|
if (!Number.isInteger(value) || value <= 0) {
|
||||||
|
throw new Error(`Invalid ${sourceLabel}: ${value} (must be a positive integer)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeCadence(value, fallback) {
|
function normalizeCadence(value, fallback) {
|
||||||
const normalized = String(value || '').trim().toLowerCase();
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
@@ -48,12 +89,13 @@ export function parseTorchConfig(raw, configPath = null) {
|
|||||||
relays: Array.isArray(nostrLock.relays)
|
relays: Array.isArray(nostrLock.relays)
|
||||||
? nostrLock.relays.map((relay) => String(relay).trim()).filter(Boolean)
|
? nostrLock.relays.map((relay) => String(relay).trim()).filter(Boolean)
|
||||||
: null,
|
: null,
|
||||||
|
relayFallbacks: parseRelayList(nostrLock.relayFallbacks),
|
||||||
ttlSeconds: Number.isFinite(nostrLock.ttlSeconds) && nostrLock.ttlSeconds > 0
|
ttlSeconds: Number.isFinite(nostrLock.ttlSeconds) && nostrLock.ttlSeconds > 0
|
||||||
? Math.floor(nostrLock.ttlSeconds)
|
? Math.floor(nostrLock.ttlSeconds)
|
||||||
: null,
|
: null,
|
||||||
queryTimeoutMs: Number.isFinite(nostrLock.queryTimeoutMs) && nostrLock.queryTimeoutMs > 0
|
queryTimeoutMs: parsePositiveInteger(nostrLock.queryTimeoutMs),
|
||||||
? Math.floor(nostrLock.queryTimeoutMs)
|
publishTimeoutMs: parsePositiveInteger(nostrLock.publishTimeoutMs),
|
||||||
: null,
|
minSuccessfulRelayPublishes: parsePositiveInteger(nostrLock.minSuccessfulRelayPublishes),
|
||||||
dailyRoster: parseRoster(nostrLock.dailyRoster),
|
dailyRoster: parseRoster(nostrLock.dailyRoster),
|
||||||
weeklyRoster: parseRoster(nostrLock.weeklyRoster),
|
weeklyRoster: parseRoster(nostrLock.weeklyRoster),
|
||||||
},
|
},
|
||||||
@@ -99,21 +141,72 @@ export function loadTorchConfig(fileSystem = fs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cachedConfig = parseTorchConfig(raw, configPath);
|
cachedConfig = parseTorchConfig(raw, configPath);
|
||||||
|
validateLockBackendConfig(cachedConfig);
|
||||||
return cachedConfig;
|
return cachedConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateLockBackendConfig(config) {
|
||||||
|
const relays = config.nostrLock.relays || [];
|
||||||
|
const relayFallbacks = config.nostrLock.relayFallbacks || [];
|
||||||
|
|
||||||
|
for (const relay of relays) {
|
||||||
|
assertValidRelayUrl(relay, 'nostrLock.relays');
|
||||||
|
}
|
||||||
|
for (const relay of relayFallbacks) {
|
||||||
|
assertValidRelayUrl(relay, 'nostrLock.relayFallbacks');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.nostrLock.queryTimeoutMs !== null) {
|
||||||
|
assertTimeoutInRange(config.nostrLock.queryTimeoutMs, 'nostrLock.queryTimeoutMs');
|
||||||
|
}
|
||||||
|
if (config.nostrLock.publishTimeoutMs !== null) {
|
||||||
|
assertTimeoutInRange(config.nostrLock.publishTimeoutMs, 'nostrLock.publishTimeoutMs');
|
||||||
|
}
|
||||||
|
if (config.nostrLock.minSuccessfulRelayPublishes !== null) {
|
||||||
|
assertPositiveCount(config.nostrLock.minSuccessfulRelayPublishes, 'nostrLock.minSuccessfulRelayPublishes');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnvRelayList(envValue, envName) {
|
||||||
|
const relays = envValue.split(',').map((r) => r.trim()).filter(Boolean);
|
||||||
|
for (const relay of relays) {
|
||||||
|
assertValidRelayUrl(relay, envName);
|
||||||
|
}
|
||||||
|
return relays;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnvInteger(envValue, envName) {
|
||||||
|
const parsed = parseInt(envValue, 10);
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
throw new Error(`Invalid ${envName}: "${envValue}" (must be an integer)`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
export function getRelays() {
|
export function getRelays() {
|
||||||
const config = loadTorchConfig();
|
const config = loadTorchConfig();
|
||||||
const envRelays = process.env.NOSTR_LOCK_RELAYS;
|
const envRelays = process.env.NOSTR_LOCK_RELAYS;
|
||||||
if (envRelays) {
|
if (envRelays) {
|
||||||
return envRelays.split(',').map((r) => r.trim()).filter(Boolean);
|
return parseEnvRelayList(envRelays, 'NOSTR_LOCK_RELAYS');
|
||||||
}
|
}
|
||||||
if (config.nostrLock.relays?.length) {
|
if (config.nostrLock.relays?.length) {
|
||||||
return config.nostrLock.relays;
|
return config.nostrLock.relays;
|
||||||
}
|
}
|
||||||
|
for (const relay of DEFAULT_RELAYS) {
|
||||||
|
assertValidRelayUrl(relay, 'DEFAULT_RELAYS');
|
||||||
|
}
|
||||||
return DEFAULT_RELAYS;
|
return DEFAULT_RELAYS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRelayFallbacks() {
|
||||||
|
const config = loadTorchConfig();
|
||||||
|
const envRelays = process.env.NOSTR_LOCK_RELAY_FALLBACKS;
|
||||||
|
if (envRelays) {
|
||||||
|
return parseEnvRelayList(envRelays, 'NOSTR_LOCK_RELAY_FALLBACKS');
|
||||||
|
}
|
||||||
|
return config.nostrLock.relayFallbacks || [];
|
||||||
|
}
|
||||||
|
|
||||||
export function getNamespace() {
|
export function getNamespace() {
|
||||||
const config = loadTorchConfig();
|
const config = loadTorchConfig();
|
||||||
const namespace = (process.env.NOSTR_LOCK_NAMESPACE || config.nostrLock.namespace || DEFAULT_NAMESPACE).trim();
|
const namespace = (process.env.NOSTR_LOCK_NAMESPACE || config.nostrLock.namespace || DEFAULT_NAMESPACE).trim();
|
||||||
@@ -137,8 +230,37 @@ export function getQueryTimeoutMs() {
|
|||||||
const config = loadTorchConfig();
|
const config = loadTorchConfig();
|
||||||
const envValue = process.env.NOSTR_LOCK_QUERY_TIMEOUT_MS;
|
const envValue = process.env.NOSTR_LOCK_QUERY_TIMEOUT_MS;
|
||||||
if (envValue) {
|
if (envValue) {
|
||||||
const parsed = parseInt(envValue, 10);
|
const parsed = parseEnvInteger(envValue, 'NOSTR_LOCK_QUERY_TIMEOUT_MS');
|
||||||
if (!Number.isNaN(parsed) && parsed > 0) return parsed;
|
assertTimeoutInRange(parsed, 'NOSTR_LOCK_QUERY_TIMEOUT_MS');
|
||||||
|
return parsed;
|
||||||
}
|
}
|
||||||
return config.nostrLock.queryTimeoutMs || DEFAULT_QUERY_TIMEOUT_MS;
|
const value = config.nostrLock.queryTimeoutMs || DEFAULT_QUERY_TIMEOUT_MS;
|
||||||
|
assertTimeoutInRange(value, 'effective query timeout');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPublishTimeoutMs() {
|
||||||
|
const config = loadTorchConfig();
|
||||||
|
const envValue = process.env.NOSTR_LOCK_PUBLISH_TIMEOUT_MS;
|
||||||
|
if (envValue) {
|
||||||
|
const parsed = parseEnvInteger(envValue, 'NOSTR_LOCK_PUBLISH_TIMEOUT_MS');
|
||||||
|
assertTimeoutInRange(parsed, 'NOSTR_LOCK_PUBLISH_TIMEOUT_MS');
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
const value = config.nostrLock.publishTimeoutMs || DEFAULT_PUBLISH_TIMEOUT_MS;
|
||||||
|
assertTimeoutInRange(value, 'effective publish timeout');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMinSuccessfulRelayPublishes() {
|
||||||
|
const config = loadTorchConfig();
|
||||||
|
const envValue = process.env.NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES;
|
||||||
|
if (envValue) {
|
||||||
|
const parsed = parseEnvInteger(envValue, 'NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES');
|
||||||
|
assertPositiveCount(parsed, 'NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES');
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
const value = config.nostrLock.minSuccessfulRelayPublishes || DEFAULT_MIN_SUCCESSFUL_PUBLISHES;
|
||||||
|
assertPositiveCount(value, 'effective min successful relay publishes');
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
+105
-103
@@ -1,121 +1,123 @@
|
|||||||
import { test, describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import assert from 'node:assert';
|
import assert from 'node:assert';
|
||||||
import { parseLockEvent } from '../src/lib.mjs';
|
import { parseLockEvent, publishLock, queryLocks } from '../src/lock-ops.mjs';
|
||||||
|
|
||||||
describe('parseLockEvent', () => {
|
describe('lock-ops', () => {
|
||||||
it('should parse a valid lock event correctly', () => {
|
describe('parseLockEvent', () => {
|
||||||
const event = {
|
it('parses a valid lock event correctly', () => {
|
||||||
id: 'mock-event-id',
|
const event = {
|
||||||
pubkey: 'mock-pubkey',
|
id: 'mock-event-id',
|
||||||
created_at: 1678886400, // 2023-03-15T13:20:00.000Z
|
pubkey: 'mock-pubkey',
|
||||||
tags: [
|
created_at: 1678886400,
|
||||||
['d', 'mock-d-tag'],
|
tags: [['d', 'mock-d-tag'], ['expiration', '1678890000']],
|
||||||
['expiration', '1678890000'], // 2023-03-15T14:20:00.000Z
|
content: JSON.stringify({
|
||||||
],
|
agent: 'test-agent',
|
||||||
content: JSON.stringify({
|
cadence: 'daily',
|
||||||
agent: 'test-agent',
|
status: 'started',
|
||||||
cadence: 'daily',
|
date: '2023-03-15',
|
||||||
status: 'started',
|
platform: 'test-platform',
|
||||||
namespace: 'test-ns',
|
}),
|
||||||
date: '2023-03-15',
|
};
|
||||||
platform: 'test-platform',
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseLockEvent(event);
|
const result = parseLockEvent(event);
|
||||||
|
|
||||||
assert.strictEqual(result.eventId, 'mock-event-id');
|
assert.strictEqual(result.dTag, 'mock-d-tag');
|
||||||
assert.strictEqual(result.pubkey, 'mock-pubkey');
|
assert.strictEqual(result.expiresAt, 1678890000);
|
||||||
assert.strictEqual(result.createdAt, 1678886400);
|
assert.strictEqual(result.agent, 'test-agent');
|
||||||
assert.strictEqual(result.createdAtIso, '2023-03-15T13:20:00.000Z');
|
assert.strictEqual(result.platform, 'test-platform');
|
||||||
assert.strictEqual(result.expiresAt, 1678890000);
|
});
|
||||||
assert.strictEqual(result.expiresAtIso, '2023-03-15T14:20:00.000Z');
|
|
||||||
assert.strictEqual(result.dTag, 'mock-d-tag');
|
|
||||||
assert.strictEqual(result.agent, 'test-agent');
|
|
||||||
assert.strictEqual(result.cadence, 'daily');
|
|
||||||
assert.strictEqual(result.status, 'started');
|
|
||||||
assert.strictEqual(result.date, '2023-03-15');
|
|
||||||
assert.strictEqual(result.platform, 'test-platform');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle missing d tag', () => {
|
describe('queryLocks', () => {
|
||||||
const event = {
|
it('falls back to fallback relays when primary query fails', async () => {
|
||||||
id: 'mock-event-id',
|
const mockPool = {
|
||||||
pubkey: 'mock-pubkey',
|
querySync: async (relays) => {
|
||||||
created_at: 1678886400,
|
if (relays[0] === 'wss://primary') {
|
||||||
tags: [],
|
throw new Error('primary unavailable');
|
||||||
content: '{}',
|
}
|
||||||
};
|
return [{
|
||||||
|
id: 'id-1',
|
||||||
|
pubkey: 'pk',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [['d', 't']],
|
||||||
|
content: JSON.stringify({ agent: 'agent1' }),
|
||||||
|
}];
|
||||||
|
},
|
||||||
|
close: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
const result = parseLockEvent(event);
|
const locks = await queryLocks(
|
||||||
assert.strictEqual(result.dTag, '');
|
['wss://primary'],
|
||||||
|
'daily',
|
||||||
|
'2026-02-14',
|
||||||
|
'torch',
|
||||||
|
{
|
||||||
|
poolFactory: () => mockPool,
|
||||||
|
getQueryTimeoutMsFn: () => 200,
|
||||||
|
getRelayFallbacksFn: () => ['wss://fallback'],
|
||||||
|
errorLogger: () => {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(locks.length, 1);
|
||||||
|
assert.strictEqual(locks[0].agent, 'agent1');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle missing expiration tag', () => {
|
describe('publishLock', () => {
|
||||||
const event = {
|
it('succeeds with partial primary success when minimum successes is 1', async () => {
|
||||||
id: 'mock-event-id',
|
const mockPool = {
|
||||||
pubkey: 'mock-pubkey',
|
publish: (relays) => relays.map((relay) => (
|
||||||
created_at: 1678886400,
|
relay.includes('ok') ? Promise.resolve('ok') : Promise.reject(new Error('offline'))
|
||||||
tags: [['d', 'mock-d-tag']],
|
)),
|
||||||
content: '{}',
|
close: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = parseLockEvent(event);
|
const event = { id: 'evt-1' };
|
||||||
assert.strictEqual(result.expiresAt, null);
|
const result = await publishLock(['wss://ok-1', 'wss://bad-1'], event, {
|
||||||
assert.strictEqual(result.expiresAtIso, null);
|
poolFactory: () => mockPool,
|
||||||
});
|
getPublishTimeoutMsFn: () => 200,
|
||||||
|
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||||
|
getRelayFallbacksFn: () => [],
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle malformed JSON content', () => {
|
assert.strictEqual(result, event);
|
||||||
const event = {
|
});
|
||||||
id: 'mock-event-id',
|
|
||||||
pubkey: 'mock-pubkey',
|
|
||||||
created_at: 1678886400,
|
|
||||||
tags: [],
|
|
||||||
content: '{ invalid json }',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseLockEvent(event);
|
it('uses fallback relays to satisfy min success quorum', async () => {
|
||||||
assert.strictEqual(result.agent, null);
|
const mockPool = {
|
||||||
assert.strictEqual(result.cadence, null);
|
publish: (relays) => relays.map((relay) => (
|
||||||
});
|
relay.includes('fallback-ok') ? Promise.resolve('ok') : Promise.reject(new Error('offline'))
|
||||||
|
)),
|
||||||
|
close: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
it('should handle non-object JSON content (string)', () => {
|
const event = { id: 'evt-2' };
|
||||||
const event = {
|
const result = await publishLock(['wss://primary-bad'], event, {
|
||||||
id: 'mock-event-id',
|
poolFactory: () => mockPool,
|
||||||
pubkey: 'mock-pubkey',
|
getPublishTimeoutMsFn: () => 200,
|
||||||
created_at: 1678886400,
|
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||||
tags: [],
|
getRelayFallbacksFn: () => ['wss://fallback-ok'],
|
||||||
content: '"just a string"',
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseLockEvent(event);
|
assert.strictEqual(result, event);
|
||||||
assert.strictEqual(result.agent, null);
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle non-object JSON content (array)', () => {
|
it('fails when successful publishes stay below configured minimum', async () => {
|
||||||
const event = {
|
const mockPool = {
|
||||||
id: 'mock-event-id',
|
publish: (relays) => relays.map(() => Promise.reject(new Error('offline'))),
|
||||||
pubkey: 'mock-pubkey',
|
close: () => {},
|
||||||
created_at: 1678886400,
|
};
|
||||||
tags: [],
|
|
||||||
content: '["item1", "item2"]',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseLockEvent(event);
|
await assert.rejects(
|
||||||
assert.strictEqual(result.agent, null);
|
() => publishLock(['wss://primary-bad'], { id: 'evt-3' }, {
|
||||||
});
|
poolFactory: () => mockPool,
|
||||||
|
getPublishTimeoutMsFn: () => 200,
|
||||||
it('should handle partial JSON content', () => {
|
getMinSuccessfulRelayPublishesFn: () => 2,
|
||||||
const event = {
|
getRelayFallbacksFn: () => ['wss://fallback-bad'],
|
||||||
id: 'mock-event-id',
|
}),
|
||||||
pubkey: 'mock-pubkey',
|
/Failed relay publish quorum in publish phase: 0\/2 successful \(required=2, timeout=200ms\)/,
|
||||||
created_at: 1678886400,
|
);
|
||||||
tags: [],
|
});
|
||||||
content: JSON.stringify({ agent: 'partial-agent' }),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseLockEvent(event);
|
|
||||||
assert.strictEqual(result.agent, 'partial-agent');
|
|
||||||
assert.strictEqual(result.cadence, null);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+72
-143
@@ -1,8 +1,18 @@
|
|||||||
import { test, describe, it, after, beforeEach } from 'node:test';
|
import { describe, it, after, beforeEach } from 'node:test';
|
||||||
import assert from 'node:assert';
|
import assert from 'node:assert';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { getTorchConfigPath, parseTorchConfig, loadTorchConfig, _resetTorchConfigCache, getRelays } from '../src/torch-config.mjs';
|
import {
|
||||||
|
getTorchConfigPath,
|
||||||
|
parseTorchConfig,
|
||||||
|
loadTorchConfig,
|
||||||
|
_resetTorchConfigCache,
|
||||||
|
getRelays,
|
||||||
|
getQueryTimeoutMs,
|
||||||
|
getPublishTimeoutMs,
|
||||||
|
getMinSuccessfulRelayPublishes,
|
||||||
|
getRelayFallbacks,
|
||||||
|
} from '../src/torch-config.mjs';
|
||||||
import { DEFAULT_RELAYS } from '../src/constants.mjs';
|
import { DEFAULT_RELAYS } from '../src/constants.mjs';
|
||||||
|
|
||||||
describe('torch-config', () => {
|
describe('torch-config', () => {
|
||||||
@@ -10,113 +20,40 @@ describe('torch-config', () => {
|
|||||||
const originalEnv = process.env.TORCH_CONFIG_PATH;
|
const originalEnv = process.env.TORCH_CONFIG_PATH;
|
||||||
|
|
||||||
after(() => {
|
after(() => {
|
||||||
if (originalEnv === undefined) {
|
if (originalEnv === undefined) delete process.env.TORCH_CONFIG_PATH;
|
||||||
delete process.env.TORCH_CONFIG_PATH;
|
else process.env.TORCH_CONFIG_PATH = originalEnv;
|
||||||
} else {
|
|
||||||
process.env.TORCH_CONFIG_PATH = originalEnv;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns default path when env var is not set', () => {
|
it('returns default path when env var is not set', () => {
|
||||||
delete process.env.TORCH_CONFIG_PATH;
|
delete process.env.TORCH_CONFIG_PATH;
|
||||||
const expected = path.resolve(process.cwd(), 'torch-config.json');
|
assert.strictEqual(getTorchConfigPath(), path.resolve(process.cwd(), 'torch-config.json'));
|
||||||
assert.strictEqual(getTorchConfigPath(), expected);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns custom path when env var is set', () => {
|
it('returns custom path when env var is set', () => {
|
||||||
process.env.TORCH_CONFIG_PATH = 'custom-config.json';
|
process.env.TORCH_CONFIG_PATH = 'custom-config.json';
|
||||||
const expected = path.resolve(process.cwd(), 'custom-config.json');
|
assert.strictEqual(getTorchConfigPath(), path.resolve(process.cwd(), 'custom-config.json'));
|
||||||
assert.strictEqual(getTorchConfigPath(), expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('trims env var value', () => {
|
|
||||||
process.env.TORCH_CONFIG_PATH = ' trimmed-config.json ';
|
|
||||||
const expected = path.resolve(process.cwd(), 'trimmed-config.json');
|
|
||||||
assert.strictEqual(getTorchConfigPath(), expected);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('parseTorchConfig', () => {
|
describe('parseTorchConfig', () => {
|
||||||
it('returns default values for empty raw config', () => {
|
it('parses new lock backend knobs', () => {
|
||||||
const config = parseTorchConfig({}, 'some-path.json');
|
const config = parseTorchConfig({
|
||||||
assert.strictEqual(config.configPath, 'some-path.json');
|
|
||||||
assert.deepStrictEqual(config.raw, {});
|
|
||||||
assert.strictEqual(config.nostrLock.namespace, null);
|
|
||||||
assert.strictEqual(config.dashboard.defaultCadenceView, 'daily');
|
|
||||||
assert.strictEqual(config.dashboard.defaultStatusView, 'active');
|
|
||||||
assert.deepStrictEqual(config.scheduler.paused.daily, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('correctly parses and normalizes nostrLock config', () => {
|
|
||||||
const raw = {
|
|
||||||
nostrLock: {
|
nostrLock: {
|
||||||
namespace: ' test ',
|
namespace: ' test ',
|
||||||
relays: [' wss://relay1 ', '', 'wss://relay2'],
|
relays: ['wss://relay1'],
|
||||||
ttlSeconds: 3600.5,
|
relayFallbacks: [' wss://fallback-1 ', 'wss://fallback-2'],
|
||||||
queryTimeoutMs: 5000,
|
queryTimeoutMs: 5000,
|
||||||
dailyRoster: ['agent1', 'agent2'],
|
publishTimeoutMs: 8000,
|
||||||
}
|
minSuccessfulRelayPublishes: 2,
|
||||||
};
|
},
|
||||||
const config = parseTorchConfig(raw);
|
});
|
||||||
|
|
||||||
assert.strictEqual(config.nostrLock.namespace, 'test');
|
assert.strictEqual(config.nostrLock.namespace, 'test');
|
||||||
assert.deepStrictEqual(config.nostrLock.relays, ['wss://relay1', 'wss://relay2']);
|
assert.deepStrictEqual(config.nostrLock.relays, ['wss://relay1']);
|
||||||
assert.strictEqual(config.nostrLock.ttlSeconds, 3600);
|
assert.deepStrictEqual(config.nostrLock.relayFallbacks, ['wss://fallback-1', 'wss://fallback-2']);
|
||||||
assert.strictEqual(config.nostrLock.queryTimeoutMs, 5000);
|
assert.strictEqual(config.nostrLock.queryTimeoutMs, 5000);
|
||||||
assert.deepStrictEqual(config.nostrLock.dailyRoster, ['agent1', 'agent2']);
|
assert.strictEqual(config.nostrLock.publishTimeoutMs, 8000);
|
||||||
});
|
assert.strictEqual(config.nostrLock.minSuccessfulRelayPublishes, 2);
|
||||||
|
|
||||||
it('normalizes cadence and status in dashboard config', () => {
|
|
||||||
const raw = {
|
|
||||||
dashboard: {
|
|
||||||
defaultCadenceView: ' WEEKLY ',
|
|
||||||
defaultStatusView: ' ALL '
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const config = parseTorchConfig(raw);
|
|
||||||
assert.strictEqual(config.dashboard.defaultCadenceView, 'weekly');
|
|
||||||
assert.strictEqual(config.dashboard.defaultStatusView, 'all');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to defaults for invalid cadence/status', () => {
|
|
||||||
const raw = {
|
|
||||||
dashboard: {
|
|
||||||
defaultCadenceView: 'invalid',
|
|
||||||
defaultStatusView: 'invalid'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const config = parseTorchConfig(raw);
|
|
||||||
assert.strictEqual(config.dashboard.defaultCadenceView, 'daily');
|
|
||||||
assert.strictEqual(config.dashboard.defaultStatusView, 'active');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('correctly parses scheduler config', () => {
|
|
||||||
const raw = {
|
|
||||||
scheduler: {
|
|
||||||
firstPromptByCadence: {
|
|
||||||
daily: ' daily-prompt.md ',
|
|
||||||
weekly: ' weekly-prompt.md '
|
|
||||||
},
|
|
||||||
paused: {
|
|
||||||
daily: [' paused1 '],
|
|
||||||
weekly: [' paused2 ']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const config = parseTorchConfig(raw);
|
|
||||||
assert.strictEqual(config.scheduler.firstPromptByCadence.daily, 'daily-prompt.md');
|
|
||||||
assert.strictEqual(config.scheduler.firstPromptByCadence.weekly, 'weekly-prompt.md');
|
|
||||||
assert.deepStrictEqual(config.scheduler.paused.daily, ['paused1']);
|
|
||||||
assert.deepStrictEqual(config.scheduler.paused.weekly, ['paused2']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles non-array roster values by returning null or empty array', () => {
|
|
||||||
const raw = {
|
|
||||||
nostrLock: { dailyRoster: 'not-an-array' },
|
|
||||||
scheduler: { paused: { daily: 'not-an-array' } }
|
|
||||||
};
|
|
||||||
const config = parseTorchConfig(raw);
|
|
||||||
assert.strictEqual(config.nostrLock.dailyRoster, null);
|
|
||||||
assert.deepStrictEqual(config.scheduler.paused.daily, []);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,82 +70,74 @@ describe('torch-config', () => {
|
|||||||
after(() => {
|
after(() => {
|
||||||
if (fs.existsSync(tempConfigPath)) fs.unlinkSync(tempConfigPath);
|
if (fs.existsSync(tempConfigPath)) fs.unlinkSync(tempConfigPath);
|
||||||
if (fs.existsSync(badConfigPath)) fs.unlinkSync(badConfigPath);
|
if (fs.existsSync(badConfigPath)) fs.unlinkSync(badConfigPath);
|
||||||
if (originalEnv === undefined) {
|
if (originalEnv === undefined) delete process.env.TORCH_CONFIG_PATH;
|
||||||
delete process.env.TORCH_CONFIG_PATH;
|
else process.env.TORCH_CONFIG_PATH = originalEnv;
|
||||||
} else {
|
|
||||||
process.env.TORCH_CONFIG_PATH = originalEnv;
|
|
||||||
}
|
|
||||||
_resetTorchConfigCache();
|
_resetTorchConfigCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads config from disk', () => {
|
it('throws fatal error for invalid relay URL', () => {
|
||||||
const raw = { nostrLock: { namespace: 'from-disk' } };
|
fs.writeFileSync(tempConfigPath, JSON.stringify({ nostrLock: { relays: ['https://bad'] } }));
|
||||||
fs.writeFileSync(tempConfigPath, JSON.stringify(raw));
|
|
||||||
process.env.TORCH_CONFIG_PATH = tempConfigPath;
|
process.env.TORCH_CONFIG_PATH = tempConfigPath;
|
||||||
|
assert.throws(() => loadTorchConfig(), /Invalid relay URL in nostrLock\.relays/);
|
||||||
const config = loadTorchConfig();
|
|
||||||
assert.strictEqual(config.nostrLock.namespace, 'from-disk');
|
|
||||||
assert.strictEqual(config.configPath, tempConfigPath);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns defaults if file does not exist', () => {
|
it('throws fatal error for invalid timeout range', () => {
|
||||||
process.env.TORCH_CONFIG_PATH = 'non-existent.json';
|
fs.writeFileSync(tempConfigPath, JSON.stringify({ nostrLock: { queryTimeoutMs: 50 } }));
|
||||||
const config = loadTorchConfig();
|
process.env.TORCH_CONFIG_PATH = tempConfigPath;
|
||||||
assert.strictEqual(config.nostrLock.namespace, null);
|
assert.throws(() => loadTorchConfig(), /Invalid nostrLock\.queryTimeoutMs/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws error for malformed JSON', () => {
|
it('throws error for malformed JSON', () => {
|
||||||
fs.writeFileSync(badConfigPath, '{ invalid json }');
|
fs.writeFileSync(badConfigPath, '{ invalid json }');
|
||||||
process.env.TORCH_CONFIG_PATH = badConfigPath;
|
process.env.TORCH_CONFIG_PATH = badConfigPath;
|
||||||
|
assert.throws(() => loadTorchConfig(), /Failed to parse/);
|
||||||
assert.throws(() => {
|
|
||||||
loadTorchConfig();
|
|
||||||
}, /Failed to parse/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('caches the config', () => {
|
|
||||||
const raw1 = { nostrLock: { namespace: 'first' } };
|
|
||||||
fs.writeFileSync(tempConfigPath, JSON.stringify(raw1));
|
|
||||||
process.env.TORCH_CONFIG_PATH = tempConfigPath;
|
|
||||||
|
|
||||||
const config1 = loadTorchConfig();
|
|
||||||
assert.strictEqual(config1.nostrLock.namespace, 'first');
|
|
||||||
|
|
||||||
const raw2 = { nostrLock: { namespace: 'second' } };
|
|
||||||
fs.writeFileSync(tempConfigPath, JSON.stringify(raw2));
|
|
||||||
|
|
||||||
const config2 = loadTorchConfig();
|
|
||||||
assert.strictEqual(config2.nostrLock.namespace, 'first', 'Should return cached value');
|
|
||||||
assert.strictEqual(config1, config2, 'Should be the same object');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getRelays', () => {
|
describe('backend getters', () => {
|
||||||
const originalEnv = process.env.NOSTR_LOCK_RELAYS;
|
const originalEnv = {
|
||||||
|
NOSTR_LOCK_RELAYS: process.env.NOSTR_LOCK_RELAYS,
|
||||||
|
NOSTR_LOCK_QUERY_TIMEOUT_MS: process.env.NOSTR_LOCK_QUERY_TIMEOUT_MS,
|
||||||
|
NOSTR_LOCK_PUBLISH_TIMEOUT_MS: process.env.NOSTR_LOCK_PUBLISH_TIMEOUT_MS,
|
||||||
|
NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES: process.env.NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES,
|
||||||
|
NOSTR_LOCK_RELAY_FALLBACKS: process.env.NOSTR_LOCK_RELAY_FALLBACKS,
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
_resetTorchConfigCache();
|
_resetTorchConfigCache();
|
||||||
delete process.env.NOSTR_LOCK_RELAYS;
|
delete process.env.NOSTR_LOCK_RELAYS;
|
||||||
|
delete process.env.NOSTR_LOCK_QUERY_TIMEOUT_MS;
|
||||||
|
delete process.env.NOSTR_LOCK_PUBLISH_TIMEOUT_MS;
|
||||||
|
delete process.env.NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES;
|
||||||
|
delete process.env.NOSTR_LOCK_RELAY_FALLBACKS;
|
||||||
});
|
});
|
||||||
|
|
||||||
after(() => {
|
after(() => {
|
||||||
if (originalEnv === undefined) {
|
for (const [k, v] of Object.entries(originalEnv)) {
|
||||||
delete process.env.NOSTR_LOCK_RELAYS;
|
if (v === undefined) delete process.env[k];
|
||||||
} else {
|
else process.env[k] = v;
|
||||||
process.env.NOSTR_LOCK_RELAYS = originalEnv;
|
|
||||||
}
|
}
|
||||||
_resetTorchConfigCache();
|
_resetTorchConfigCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns default relays when no config or env var is set', () => {
|
it('returns default relays', () => {
|
||||||
const relays = getRelays();
|
assert.deepStrictEqual(getRelays(), DEFAULT_RELAYS);
|
||||||
assert.deepStrictEqual(relays, DEFAULT_RELAYS);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns relays from env var if set', () => {
|
it('validates env knob values', () => {
|
||||||
process.env.NOSTR_LOCK_RELAYS = 'wss://env.relay,wss://env.relay2';
|
process.env.NOSTR_LOCK_QUERY_TIMEOUT_MS = '99';
|
||||||
const relays = getRelays();
|
assert.throws(() => getQueryTimeoutMs(), /NOSTR_LOCK_QUERY_TIMEOUT_MS/);
|
||||||
assert.deepStrictEqual(relays, ['wss://env.relay', 'wss://env.relay2']);
|
|
||||||
|
process.env.NOSTR_LOCK_QUERY_TIMEOUT_MS = '5000';
|
||||||
|
process.env.NOSTR_LOCK_PUBLISH_TIMEOUT_MS = 'abc';
|
||||||
|
assert.throws(() => getPublishTimeoutMs(), /NOSTR_LOCK_PUBLISH_TIMEOUT_MS/);
|
||||||
|
|
||||||
|
process.env.NOSTR_LOCK_PUBLISH_TIMEOUT_MS = '8000';
|
||||||
|
process.env.NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES = '0';
|
||||||
|
assert.throws(() => getMinSuccessfulRelayPublishes(), /NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES/);
|
||||||
|
|
||||||
|
process.env.NOSTR_LOCK_RELAY_FALLBACKS = 'https://bad';
|
||||||
|
assert.throws(() => getRelayFallbacks(), /NOSTR_LOCK_RELAY_FALLBACKS/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -7,7 +7,13 @@
|
|||||||
"wss://relay.primal.net"
|
"wss://relay.primal.net"
|
||||||
],
|
],
|
||||||
"ttlSeconds": 7200,
|
"ttlSeconds": 7200,
|
||||||
"queryTimeoutMs": 15000
|
"queryTimeoutMs": 15000,
|
||||||
|
"publishTimeoutMs": 8000,
|
||||||
|
"minSuccessfulRelayPublishes": 2,
|
||||||
|
"relayFallbacks": [
|
||||||
|
"wss://relay.snort.social",
|
||||||
|
"wss://relay.nostr.band"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"defaultCadenceView": "daily",
|
"defaultCadenceView": "daily",
|
||||||
|
|||||||
Reference in new issue
Block a user