Skip to content

Dev#35

Merged
AustinKelsay merged 76 commits into
masterfrom
dev
May 28, 2026
Merged

Dev#35
AustinKelsay merged 76 commits into
masterfrom
dev

Conversation

@AustinKelsay

@AustinKelsay AustinKelsay commented Feb 4, 2026

Copy link
Copy Markdown
Member

Summary

This PR prepares the v1.2.0 release across the API, frontend, runtime, and docs.

Highlights

  • add update-check support and frontend update banner
  • add WebSocket events plus persisted UI event logs, pagination, and export
  • harden auth, env, relay, and protected-route behavior
  • improve headless/runtime/deployment behavior and CI workflows
  • expand docs and OpenAPI coverage
  • add targeted tests and reliability fixes

Verification

  • bun run build
  • bun test
  • bun run docs:validate

Summary by CodeRabbit

  • New Features

    • Update banner and /api/update check; real‑time event stream via WebSocket.
    • Persisted UI event log: paginated view, lazy payload fetch, NDJSON export, download and “load older” controls.
    • Expanded NIP‑46 session/request management and UI controls.
  • Improvements

    • Mode-specific rate limits and headless startup optimizations.
    • Background relay probing, stronger relay/peer handling, incremental history loading, improved accessibility and clearer transport/session feedback.
  • Documentation

    • Added CONFIG, AUTH_MATRIX, PEER_POLICIES, EVENT_LOG and other reference guides.

@coderabbitai

coderabbitai Bot commented Feb 4, 2026

Copy link
Copy Markdown
Contributor

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3c8ea44f-bfc2-405e-bcae-5f675a8d71cd

📥 Commits

Reviewing files that changed from the base of the PR and between d2db2e9 and 918cfa3.

📒 Files selected for processing (32)
  • .factory/init.sh
  • .factory/library/architecture.md
  • .factory/library/environment.md
  • .factory/library/release.md
  • .factory/library/testing.md
  • .factory/library/tooling-quirks.md
  • .factory/library/user-testing.md
  • .factory/services.yaml
  • .factory/skills/interop-backend-worker/SKILL.md
  • .factory/skills/release-rollout-worker/SKILL.md
  • .factory/validation/nip44-api-fix/scrutiny/reviews/http-nip44-standards-interop.json
  • .factory/validation/nip44-api-fix/scrutiny/synthesis.json
  • .factory/validation/nip44-api-fix/user-testing/flows/nip44-contract.json
  • .factory/validation/nip44-api-fix/user-testing/flows/nip44-interop.json
  • .factory/validation/nip44-api-fix/user-testing/synthesis.json
  • .factory/validation/nip46-fix-and-polish/scrutiny/reviews/nip46-nip44-rpc-standards-interop.json
  • .factory/validation/nip46-fix-and-polish/scrutiny/synthesis.json
  • .factory/validation/nip46-fix-and-polish/user-testing/flows/nip46-interop.json
  • .factory/validation/nip46-fix-and-polish/user-testing/flows/nip46-policy-cross.json
  • .factory/validation/nip46-fix-and-polish/user-testing/synthesis.json
  • .factory/validation/validation-readiness/scrutiny/reviews/validation-readiness-source-repo.json
  • .factory/validation/validation-readiness/scrutiny/synthesis.json
  • .factory/validation/validation-readiness/user-testing/flows/command-validators.json
  • .factory/validation/validation-readiness/user-testing/flows/http-smoke.json
  • .factory/validation/validation-readiness/user-testing/synthesis.json
  • docs/openapi/openapi.yaml
  • src/nip46/service.ts
  • src/routes/crypto-utils.ts
  • src/routes/nip44.ts
  • tests/routes/nip44.spec.ts
  • tests/routes/nip46.spec.ts
  • tests/routes/protected.api.spec.ts

📝 Walkthrough
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev

@AustinKelsay
AustinKelsay marked this pull request as ready for review February 9, 2026 22:09

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/SECURITY.md (1)

671-675: ⚠️ Potential issue | 🟡 Minor

Orphaned content at end of file.

Line 672 starts with 4. **Directional Peer Policies** right after the closing "Remember: Security is a process…" paragraph, without any parent section heading. This numbered item appears to be a leftover fragment — it should either be relocated into the relevant section (e.g., under Headless Mode Setup or Network Security) or removed if it's redundant with the peer policies mention on line 22.

🤖 Fix all issues with AI agents
In `@CHANGELOG.md`:
- Around line 3-11: The 1.1.1 changelog entry under header "[1.1.1] —
2026-02-04" is incomplete; update CHANGELOG.md by expanding the 1.1.1 section
into appropriate subsections (Added/Changed/Fixed) and list all PR changes:
relay probe optimizations, Docker Compose improvements, new docs (CONFIG.md,
AUTH_MATRIX.md, PEER_POLICIES.md), headless-mode session optimizations,
WebSocket events endpoint, and the update-check banner backed by /api/update;
keep the existing version/notes lines and ensure each bullet is concise and
placed under the correct subsection (e.g., Added for new docs and endpoints,
Changed for optimizations/improvements).

In `@compose.yml`:
- Line 22: Remove the hardcoded "platform: linux/x86_64" entry in the
docker-compose service (or make it configurable) because it forces x86 on ARM
hosts; update the compose.yml to either delete the platform: linux/x86_64 line
or replace it with a variable (e.g., ${PLATFORM}) and document/define that env
var so ARM deployments (Raspberry Pi / Apple Silicon / Umbrel) can run without
emulation.
- Around line 41-48: The compose.yml currently bind-mounts the host src
directory via the volumes entry "./src:/app/src:rw", which bypasses the image
build and is unsafe for production; either remove that volumes entry entirely or
move it behind a development-only configuration (e.g., add a "profiles: [dev]"
wrapper or put the mount into a docker-compose.override.yml) and update the file
comment to explicitly state that the bind-mount is for local development only;
ensure the persistent mounts (./.env and ./data) remain unchanged and that any
removal preserves reproducible image behavior for the services referencing the
mounted src path.

In `@docs/AUTH_MATRIX.md`:
- Around line 14-15: The table header row containing the "Purpose" column uses
right-alignment markup (`---:`) causing the "Purpose" column to be
right-aligned; update that header cell's alignment marker to left alignment
(change the `---:` for the "Purpose" column to `---`) so the "Purpose" column
aligns with other text columns in the AUTH_MATRIX.md table header row.

In `@env.example`:
- Around line 111-119: Remove the duplicate WebSocket-specific env var blocks
from the RATE LIMITING section and keep the canonical definitions only in the
WEBSOCKET ABUSE PROTECTION (ADVANCED) section; specifically ensure
RATE_LIMIT_WS_UPGRADE_WINDOW, RATE_LIMIT_WS_UPGRADE_MAX,
WS_MAX_CONNECTIONS_PER_IP, WS_MSG_RATE, and WS_MSG_BURST are defined once in the
ADVANCED block, and replace the removed block in the RATE LIMITING section with
a single brief cross-reference line pointing users to the ADVANCED WebSocket
abuse protection section for these settings.

In `@llm/context/NIP46_IMPLEMENTATION.md`:
- Around line 261-263: Update the fenced code block containing the nostrconnect
example so it includes a language tag (e.g., ```text) to satisfy markdownlint;
specifically change the fence from ``` to ```text for the block that shows
"nostrconnect://pubkey?relay=wss://...&perms=sign_event:1,sign_event:4,nip44_encrypt".
- Around line 170-173: The table rows for the requests endpoints contain
unescaped pipe characters inside inline code (notably the `POST /requests`
action list like `action=approve|deny|fail|complete`) which breaks the Markdown
table; find the inline code spans in NIP46_IMPLEMENTATION.md that include '|'
(e.g., the `action=...` value and any other param values containing '|') and
escape each pipe as '\|' inside the backticks (or replace the inline code with a
code span that doesn't include raw '|'), ensuring the table columns render
correctly for the `GET /requests`, `POST /requests`, and `DELETE /requests`
rows.
🧹 Nitpick comments (10)
docs/RELEASE.md (1)

37-40: Hard-coded version in release branch examples will become stale.

The branch name release/prepare-v1.1.1 and the PR description are specific to this release. Consider using a placeholder like release/prepare-vX.Y.Z so the guide remains useful for future releases without needing edits each time.

Proposed change
-git checkout -b release/prepare-v1.1.1
-git push origin release/prepare-v1.1.1
+git checkout -b release/prepare-vX.Y.Z
+git push origin release/prepare-vX.Y.Z
-Create PR: `release/prepare-v1.1.1` → `master`
+Create PR: `release/prepare-vX.Y.Z` → `master`
package.json (1)

36-37: typecheck and tsc scripts are identical.

Both run tsc --noEmit. If this is intentional for discoverability (e.g., bun run tsc as a shorthand), that's fine — but you could also alias one to the other to keep a single source of truth:

-    "typecheck": "tsc --noEmit",
-    "tsc": "tsc --noEmit"
+    "typecheck": "tsc --noEmit",
+    "tsc": "bun run typecheck"
src/routes/auth.ts (1)

788-799: Timer skip is consistent with the session-disable path, but consider guarding stopAuthCleanup defensively.

The condition !HEADLESS || !process.env.API_KEY correctly mirrors the validateSessionSecret fast-path. stopAuthCleanup already null-checks the timer handles, so shutdown is safe when timers were never started.

One minor note: the condition process.env.API_KEY here reads the env var directly at module-load time, while AUTH_CONFIG.API_KEY (line 240) applies additional gating (HEADLESS ? process.env.API_KEY : undefined). Since both checks are already guarded by HEADLESS, they're equivalent here—but using AUTH_CONFIG.API_KEY would be more consistent and avoid a subtle divergence if the gating logic ever changes.

♻️ Use AUTH_CONFIG.API_KEY for consistency
-// Skip timers in headless mode with API key - sessions are disabled anyway (perf optimization 2.3)
-if (!HEADLESS || !process.env.API_KEY) {
+// Skip timers in headless mode with API key - sessions are disabled anyway (perf optimization 2.3)
+if (!HEADLESS || !AUTH_CONFIG.API_KEY) {

Similarly in validateSessionSecret:

-  if (HEADLESS && process.env.API_KEY) {
+  if (HEADLESS && AUTH_CONFIG.API_KEY) {

Note: AUTH_CONFIG is defined after validateSessionSecret is called (line 245), so this refactor would require reordering—moving the API_KEY resolution before validateSessionSecret(). If that's too disruptive, the current approach is fine.

src/routes/update.ts (3)

40-42: Numeric env vars are not clamped or NaN-guarded, unlike other config values in the codebase.

Other timeout config in the codebase (e.g., FROSTR_SIGN_TIMEOUT, CONNECTIVITY_PING_TIMEOUT_MS per docs/CONFIG.md) clamp values to safe ranges. Here, a non-numeric UPDATE_CHECK_TIMEOUT_MS would produce NaN, and setTimeout(..., NaN) fires immediately — causing every GitHub fetch to abort instantly.

Consider adding simple guards:

Proposed fix — clamp to safe ranges
-const UPDATE_CHECK_TIMEOUT_MS = parseInt(process.env['UPDATE_CHECK_TIMEOUT_MS'] ?? '5000', 10);
-const UPDATE_CHECK_TTL_MS = parseInt(process.env['UPDATE_CHECK_TTL_MS'] ?? '21600000', 10); // 6 hours
-const UPDATE_CHECK_FAILURE_TTL_MS = parseInt(process.env['UPDATE_CHECK_FAILURE_TTL_MS'] ?? '900000', 10); // 15 minutes
+const UPDATE_CHECK_TIMEOUT_MS = Math.min(Math.max(parseInt(process.env['UPDATE_CHECK_TIMEOUT_MS'] ?? '5000', 10) || 5000, 1000), 30000);
+const UPDATE_CHECK_TTL_MS = Math.min(Math.max(parseInt(process.env['UPDATE_CHECK_TTL_MS'] ?? '21600000', 10) || 21600000, 60000), 86400000);
+const UPDATE_CHECK_FAILURE_TTL_MS = Math.min(Math.max(parseInt(process.env['UPDATE_CHECK_FAILURE_TTL_MS'] ?? '900000', 10) || 900000, 60000), 3600000);

54-58: parseBoolean duplicates pattern from src/const.ts.

The boolean parsing logic here mirrors the IIFE pattern used for HEADLESS, SKIP_ADMIN_SECRET_VALIDATION, etc. in src/const.ts. Consider extracting a shared parseEnvBoolean utility to reduce duplication, especially since the const.ts version also handles 'yes'.


237-247: No deduplication of concurrent fetches while cache is stale.

If multiple requests arrive simultaneously after cache expiry, each will trigger an independent GitHub API fetch. For a public, unauthenticated endpoint with a 6-hour default TTL this is low-risk, but a simple in-flight promise could prevent redundant calls.

src/server.ts (2)

749-754: Duplicate WS rate-limit config parsing.

The env-parsing block for RATE_LIMIT_WS_UPGRADE_WINDOW / RATE_LIMIT_WS_UPGRADE_MAX is copy-pasted between the /api/events and / WebSocket upgrade paths (lines 749-754 vs 856-861). Consider extracting a small helper to avoid divergence.

♻️ Extract shared WS upgrade rate-limit config
+// Near the top with other WS config constants
+const WS_UPGRADE_WINDOW_MS = (() => {
+  const raw = process.env.RATE_LIMIT_WS_UPGRADE_WINDOW ?? process.env.RATE_LIMIT_WINDOW ?? '900';
+  const parsed = Number.parseInt(raw, 10);
+  return Math.max(1000, (Number.isFinite(parsed) ? parsed : 900) * 1000);
+})();
+const WS_UPGRADE_MAX = (() => {
+  const raw = process.env.RATE_LIMIT_WS_UPGRADE_MAX ?? '30';
+  const parsed = Number.parseInt(raw, 10);
+  return Math.max(1, Number.isFinite(parsed) ? parsed : 30);
+})();

Then replace the inline parsing in both upgrade paths with wsUpWindow: WS_UPGRADE_WINDOW_MS and wsUpMax: WS_UPGRADE_MAX.

Also applies to: 856-861


542-555: Startup echoes are restricted to headless mode only — the same functions ARE actively used in database mode.

Verified: startup echo broadcasts (lines 549, 552) are gated to CONST.HEADLESS && !CONST.SKIP_STARTUP_ECHO, yet sendSelfEcho() and broadcastShareEcho() are also called unconditionally in src/routes/user.ts and src/routes/env.ts after credential updates in database mode. This pattern suggests intentional design (startup verification in headless mode; update-time verification in both modes). If this distinction is deliberate, an inline comment clarifying the different verification strategies would improve readability.

src/const.ts (1)

92-125: Consider extracting a shared boolean env parser.

The truthy-value parsing pattern (true/1/yes) is now repeated 5+ times across this file. A small helper would reduce duplication and ensure consistent behavior:

♻️ Proposed helper
+/** Parse a boolean environment variable (true/1/yes → true, everything else → false). */
+function parseBoolEnv(key: string): boolean {
+  const value = process.env[key];
+  if (!value) return false;
+  const trimmed = value.trim();
+  if (trimmed.length === 0) return false;
+  const normalized = trimmed.toLowerCase();
+  return normalized === 'true' || normalized === '1' || normalized === 'yes';
+}
+
-export const SKIP_RELAY_PROBE = (() => {
-  const value = process.env['SKIP_RELAY_PROBE'];
-  if (!value) return false;
-  const trimmed = value.trim();
-  if (trimmed.length === 0) return false;
-  const normalized = trimmed.toLowerCase();
-  return normalized === 'true' || normalized === '1' || normalized === 'yes';
-})();
+export const SKIP_RELAY_PROBE = parseBoolEnv('SKIP_RELAY_PROBE');
+export const DEFER_RELAY_PROBE = parseBoolEnv('DEFER_RELAY_PROBE');
+export const SKIP_STARTUP_ECHO = parseBoolEnv('SKIP_STARTUP_ECHO');

The existing HEADLESS and SKIP_ADMIN_SECRET_VALIDATION could also be migrated in a follow-up.

src/node/manager.ts (1)

730-734: Return defensive copies for background probe results.
Avoid exposing mutable arrays/objects from the shared cache.

♻️ Suggested change
-    lastBackgroundProbeResult = {
-      originalRelays: relays,
-      filteredRelays: filtered,
-      timestamp: Date.now()
-    };
+    lastBackgroundProbeResult = {
+      originalRelays: [...relays],
+      filteredRelays: [...filtered],
+      timestamp: Date.now()
+    };
-export function getLastBackgroundProbeResult(): BackgroundProbeResult | null {
-  return lastBackgroundProbeResult;
-}
+export function getLastBackgroundProbeResult(): BackgroundProbeResult | null {
+  if (!lastBackgroundProbeResult) return null;
+  return {
+    ...lastBackgroundProbeResult,
+    originalRelays: [...lastBackgroundProbeResult.originalRelays],
+    filteredRelays: [...lastBackgroundProbeResult.filteredRelays]
+  };
+}

Also applies to: 766-768

Comment thread compose.yml Outdated
Comment thread compose.yml
Comment thread docs/AUTH_MATRIX.md Outdated
Comment thread env.example Outdated
Comment thread llm/context/NIP46_IMPLEMENTATION.md
Comment thread llm/context/NIP46_IMPLEMENTATION.md Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@frontend/components/Signer.tsx`:
- Around line 560-589: handleLoadOlder can repeatedly request the same beforeSeq
when no entry IDs parse to numbers (seqs is empty) because minSeq falls back to
oldestSeq; add a defensive guard: after computing seqs, if seqs.length === 0
then call setHasMoreHistory(false) (and skip updating oldestSeq) to break the
loop and avoid re-requesting the same beforeSeq; reference the
variables/functions handleLoadOlder, seqs, minSeq, oldestSeq, setHasMoreHistory,
and setOldestSeq when applying the change.

In `@src/db/ui-event-log.ts`:
- Around line 267-317: Define a concrete row type and a typed params array
instead of using any: add an interface (e.g. UiEventRow) describing the SELECT
result fields used in list (seq, created_at_ms, created_at, data_preview,
data_hash, data_bytes, type, message) with appropriate unions
(number|string|null where values may be strings or numbers), change params from
any[] to Array<string|number|null> (or more specific unions) and annotate rows
as UiEventRow[] (and the stmt.all return) so the mapping logic uses proper
types; update the variables referenced in list (params, rows) and keep the same
call to stmt.all(...params, limit).
- Around line 149-174: The repo contains an orphaned SQL migration that
duplicates the schema created by ensureUiEventLogSchema; either delete the
migration file (the one creating ui_event_log_blobs and ui_event_log_entries) to
avoid confusion, or move its DDL into your central migration mechanism so
runMigrations() will apply it; update
createUiEventLogStore/ensureUiEventLogSchema to no longer conflict with the
migration path (i.e., if you integrate, remove the inline exec() calls in
ensureUiEventLogSchema or mark them as no-ops when migrations are used) and
ensure schema ownership is singular and tracked by runMigrations()/the migration
system.

In `@src/routes/update.ts`:
- Around line 40-42: The parsed env constants UPDATE_CHECK_TIMEOUT_MS,
UPDATE_CHECK_TTL_MS and UPDATE_CHECK_FAILURE_TTL_MS can become NaN for garbage
env values; update their initialization to validate the parsed result and fall
back to the intended numeric defaults when Number.isNaN or !Number.isFinite is
true (e.g., parseInt(..., 10) -> const v = parseInt(..., 10); const
UPDATE_CHECK_TTL_MS = Number.isFinite(v) ? v : 21600000), ensuring the rest of
the cache logic (e.g., isCacheValid) receives a valid number.
- Around line 79-107: The parseVersion function currently uses
withoutPrefix.split('-', 2) which drops any additional hyphenated segments in
prerelease identifiers (e.g., "alpha-1"); change this to split only on the first
hyphen so the prerelease captures the entire trailing string. Replace the
destructuring with logic like: find the first '-' via
withoutPrefix.indexOf('-'), set core = withoutPrefix when no '-' or
withoutPrefix.slice(0, idx) when present, and set prerelease = idx === -1 ?
undefined : withoutPrefix.slice(idx + 1); keep the rest of parseVersion
(validation of core parts, numeric conversion, allowPrerelease check, and
returned shape) unchanged and use prerelease ?? null when setting the returned
object.
🧹 Nitpick comments (14)
src/db/nip46.ts (1)

339-370: Cursor-based pagination implementation looks correct.

The composite (created_at, id) cursor with the (created_at < ? OR (created_at = ? AND id < ?)) pattern is the right approach for deterministic keyset pagination over a DESC ordering. Parameterized queries properly prevent injection.

One minor observation: the before.createdAt value is only checked for being a non-empty string (line 359) but is not validated as a well-formed datetime. A malformed value won't cause injection (it's parameterized), but it will silently return unexpected results. The route layer (reviewed next) also doesn't validate the format — consider adding a lightweight ISO-8601 check in either layer.

src/routes/nip46.ts (1)

296-314: Cursor pagination wiring looks good overall.

The both-or-neither validation (lines 300–305) and cursor construction are clean. The nextCursor heuristic (full page → more data) is standard keyset pagination behavior.

Note: beforeCreatedAt is passed through to the DB layer without any format validation. While parameterized queries prevent injection, an arbitrary string like "hello" would silently return zero results rather than a 400 error. A lightweight datetime sanity check (e.g., regex or Date.parse) would give callers better feedback.

frontend/components/ui/log-entry.tsx (1)

100-130: Lazy-loading effect is well-structured with proper cleanup.

The cancellation flag pattern, hash format validation, and guard conditions (!isMessageExpanded, hasFetchedFull, fetchError) are all correct. One small note:

Line 115 trusts (payload as any).data without verifying the shape. If the endpoint returns { data: null }, resolvedData will be set to null and hasFetchedFull to true, which means the preview is also lost. Consider gating on payload.data != null before overwriting.

Proposed guard
       .then(payload => {
         if (cancelled) return;
         if (payload && typeof payload === 'object' && 'data' in payload) {
-          setResolvedData((payload as any).data);
-          setHasFetchedFull(true);
+          const fetched = (payload as any).data;
+          if (fetched != null) {
+            setResolvedData(fetched);
+            setHasFetchedFull(true);
+          }
         }
       })
src/routes/update.ts (2)

54-58: parseBoolean duplicates logic already in src/const.ts.

The same truthy-string parsing pattern exists in src/const.ts for HEADLESS and SKIP_ADMIN_SECRET_VALIDATION. Consider extracting a shared utility to avoid drift between the two implementations.


238-248: Concurrent requests may trigger parallel GitHub fetches.

Multiple simultaneous requests hitting an expired cache will each call fetchLatestVersion() before either can write back to cachedUpdate. This is a minor inefficiency rather than a correctness bug (last write wins, all return valid data). For a 6-hour TTL endpoint this is unlikely to matter in practice, but if you want to tighten it, you could store the in-flight promise and deduplicate.

src/node/manager.ts (2)

650-659: Background probe state: consider exporting BackgroundProbeResult.

getLastBackgroundProbeResult() is exported and returns BackgroundProbeResult | null, but the interface itself is not exported. Consumers would have to use ReturnType<typeof getLastBackgroundProbeResult> to reference this type. Exporting the interface would make it easier to type downstream code.

♻️ Export the interface
-interface BackgroundProbeResult {
+export interface BackgroundProbeResult {
   originalRelays: string[];
   filteredRelays: string[];
   timestamp: number;
 }

1338-1357: Persistence hook integration looks solid; minor semicolon inconsistency on Line 1350.

The persist callback is correctly guarded by try/catch and falls back to ephemeral IDs when persistence is unavailable. The ISO timestamp change is a good improvement for consistent cross-timezone display.

Line 1350 is missing a semicolon after the assignment (const seq = opts?.persist?.(logEntry)), while most surrounding statements use them. This won't break anything since ASI handles it, but it's inconsistent with the local style.

Style consistency
-      const seq = opts?.persist?.(logEntry)
+      const seq = opts?.persist?.(logEntry);
frontend/components/Signer.tsx (1)

521-558: Potential unnecessary re-fetches when authHeaders is an unstable reference.

The dependency array [authHeaders, isHeadlessMode] will cause this effect to re-run whenever authHeaders is a new object reference (even with identical content). Since the default is authHeaders = {}, a new object is created on every render if the parent doesn't memoize it.

The de-duplication merge logic prevents visible bugs, but the redundant network requests are wasteful. Consider using authHeadersRef (already established at Line 188) here as well, or memoizing authHeaders in the parent.

src/routes/event-log.ts (1)

5-24: parseSeq and parseBeforeSeq are identical — consider consolidating.

Both functions parse a nullable string into a positive integer or undefined with identical logic. This is a minor DRY opportunity.

♻️ Consolidate into a single helper
-function parseSeq(value: string | null): number | undefined {
-  if (!value) return undefined
-  const n = Number.parseInt(value, 10)
-  if (!Number.isFinite(n) || n <= 0) return undefined
-  return n
-}
-
 function parseLimit(value: string | null, fallback = 200): number {
   if (!value) return fallback
   const n = Number.parseInt(value, 10)
   if (!Number.isFinite(n)) return fallback
   return Math.min(Math.max(n, 1), 500)
 }
 
-function parseBeforeSeq(value: string | null): number | undefined {
+function parsePositiveInt(value: string | null): number | undefined {
   if (!value) return undefined
   const n = Number.parseInt(value, 10)
   if (!Number.isFinite(n) || n <= 0) return undefined
   return n
 }
src/db/ui-event-log.test.ts (1)

6-38: Thorough test covering append, dedup, pagination, and blob retrieval.

Good coverage of the core store operations. The test correctly validates blob content-addressing (same data → same hash → single blob row) and cursor-based pagination semantics.

Minor nit: ensureUiEventLogSchema(mem) on Line 8 is redundant since createUiEventLogStore(mem) on Line 9 calls it internally (see ui-event-log.ts Line 176). Removing it would simplify setup without changing behavior.

src/db/ui-event-log.ts (4)

63-78: Broad substring match on 'token' may over-redact benign fields.

k.includes('token') on line 76 will redact fields like token_type, tokenCount, tokenIndex, etc. The comment says "redact when we're confident," but a substring match is fairly aggressive. Consider using a word-boundary approach or an allowlist for known-safe token* keys if over-redaction becomes a problem in practice.


176-206: Missing explicit return type on createUiEventLogStore.

The function relies entirely on type inference for its return type. Per coding guidelines, explicit types should be declared. Consider defining an interface for the store's public API.

Suggested approach
interface UiEventLogStore {
  append(entry: UiEventLogStreamEntry): { seq: number; dataHash: string | null };
  list(opts?: { limit?: number; beforeSeq?: number; types?: string[] }): UiEventLogListResult;
  getBlob(hash: string): { data: unknown; byteLength: number } | null;
  exportChunk(opts?: { afterSeq?: number; untilSeq?: number; limit?: number; types?: string[] }): { rows: UiEventLogExportRow[]; nextAfterSeq: number | null };
}

Then: export function createUiEventLogStore(dbConn: Database): UiEventLogStore { ... }

As per coding guidelines, "TypeScript strict mode; explicit types, avoid any".


209-265: Consider wrapping blob + entry inserts in a transaction.

insertBlob.run (line 235/244) and insertEntry.run (line 253) are not wrapped in a transaction. If the entry insert fails after the blob was stored, you get an orphaned blob. While harmless due to content-addressing, wrapping in a transaction would be cleaner and prevent accumulation of unused blobs over time.


63-78: Consider adding Nostr-specific sensitive keys to the redaction list.

Given this is a FROSTR/Nostr project, keys like nsec, privkey, private_key, or bunker_secret could carry secret material but won't be caught by the current checks (none contain "secret" or "token" as substrings). Consider adding them explicitly.

Suggested addition
   if (k === 'derived_key' || k === 'derivedkey') return true
+  if (k === 'nsec' || k === 'privkey' || k === 'private_key' || k === 'sk') return true
   if (k.includes('secret') || k.includes('token')) return true

Comment thread frontend/components/Signer.tsx
Comment thread src/db/ui-event-log.ts
Comment thread src/db/ui-event-log.ts
Comment thread src/routes/update.ts Outdated
Comment thread src/routes/update.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@docs/AUTH_MATRIX.md`:
- Line 17: The AUTH_MATRIX entry for `/api/update` is contradictory: the table
marks the endpoint as available ("Yes") in headless/managed modes but the note
says update checks are disabled when HEADLESS=true or
SKIP_ADMIN_SECRET_VALIDATION=true/UPDATE_CHECK_DISABLED=true; update the docs so
behavior is explicit—either change the table cell for `/api/update` to indicate
"No (disabled)" for managed/headless deployments or reword the note to state
that the endpoint exists but returns a "disabled" response/status when HEADLESS,
SKIP_ADMIN_SECRET_VALIDATION, or UPDATE_CHECK_DISABLED are set (mention
`/api/update` and the three env vars by name).

In `@frontend/components/Signer.tsx`:
- Around line 1007-1012: handleClearLogs currently resets state including
oldestSeq to null which permanently disables the handleLoadOlder guard
(!oldestSeq) and prevents reloading history; to fix, either stop clearing
oldestSeq in handleClearLogs or after clearing call the initial-history loader
to repopulate oldestSeq (invoke the same code path used by
handleLoadOlder/initial load, e.g., trigger the function that loads the first
page or call handleLoadOlder with a reset flag) and keep using
setLogs/setHasMoreHistory as needed so the "load older" button becomes
functional again; update handleClearLogs and/or add a small wrapper (e.g.,
reloadHistory or resetAndLoadHistory) and reference handleClearLogs,
handleLoadOlder, oldestSeq, setOldestSeq, and setHasMoreHistory.

In `@src/routes/update.ts`:
- Around line 111-121: The compareVersions function uses localeCompare for
prerelease which is not semver-compliant; update compareVersions (and the
handling of ParsedVersion.prerelease) to split prerelease strings by '.' and
compare each identifier per SemVer rules: treat purely-numeric identifiers as
integers and compare numerically, treat non-numeric identifiers by ASCII lexical
order, numeric identifiers have lower precedence than non-numeric, and if all
compared identifiers are equal the shorter identifier list has lower precedence
(i.e., fewer fields < more fields); replace the localeCompare branch with this
dot-separated, numeric-aware comparison.
🧹 Nitpick comments (10)
compose.yml (1)

40-47: The .env bind-mount directory-creation pitfall is documented but not guarded.

If a user runs docker compose up without first creating .env, Docker will silently create ./.env/ as a directory, breaking both the env_file directive and in-app reads. The comments warn about this, but users may not read them.

Consider adding a lightweight guard to fail fast, for example via an entrypoint wrapper or a Compose-level check:

Option: Makefile / wrapper script guard
up:
	`@test` -f .env || (echo "ERROR: .env file not found. Run 'cp env.example .env' first." && exit 1)
	docker compose up -d

Alternatively, if you have a custom entrypoint in the Dockerfile, you could validate /app/.env is a regular file at startup.

docs/AUTH_MATRIX.md (1)

38-38: Minor: Consider wording consistency.

For consistency with line 10's phrasing ("does not mean"), consider changing "is not" to "does not mean":

-`AUTH_ENABLED=false` is not "open everything".
+`AUTH_ENABLED=false` does not mean "open everything".

This maintains a consistent documentation voice throughout the file.

src/routes/update.ts (2)

54-58: parseBoolean duplicates the pattern already in src/const.ts.

The IIFE bodies of HEADLESS and SKIP_ADMIN_SECRET_VALIDATION in src/const.ts implement the identical trim → lowercase → check logic. Consider extracting a shared parseBoolean utility (e.g., in src/const.ts or src/routes/utils.ts) and reusing it in both places.


240-250: Concurrent requests may trigger redundant GitHub fetches.

When cachedUpdate is null or stale, multiple simultaneous requests will all pass the isCacheValid check and independently call fetchLatestVersion(). This is a thundering-herd scenario that could burn through GitHub's unauthenticated rate limit (60 requests/hour).

A simple guard (e.g., storing a pending Promise and deduplicating) would prevent redundant fetches:

💡 Sketch of a deduplication approach
+let pendingFetch: Promise<void> | null = null;
+
+async function refreshCache(): Promise<void> {
+  try {
+    const result = await fetchLatestVersion();
+    cachedUpdate = { fetchedAt: Date.now(), result };
+  } catch (error) {
+    cachedUpdate = {
+      fetchedAt: Date.now(),
+      error: error instanceof Error ? error.message : 'Unknown update check error'
+    };
+  }
+}

   if (!cachedUpdate || !isCacheValid(cachedUpdate)) {
-    try {
-      const result = await fetchLatestVersion();
-      cachedUpdate = { fetchedAt: Date.now(), result };
-    } catch (error) {
-      cachedUpdate = {
-        fetchedAt: Date.now(),
-        error: error instanceof Error ? error.message : 'Unknown update check error'
-      };
-    }
+    if (!pendingFetch) {
+      pendingFetch = refreshCache().finally(() => { pendingFetch = null; });
+    }
+    await pendingFetch;
   }
frontend/components/Signer.tsx (3)

521-558: authHeaders in the dependency array may cause repeated fetches on every render.

If the parent component creates a new authHeaders object on each render (common with inline object literals), this effect will re-run and re-fetch the initial history every time. Since you already maintain authHeadersRef for the WebSocket effect, consider reusing it here:

♻️ Suggested fix
   useEffect(() => {
     let cancelled = false;
     const load = async () => {
       try {
-        const res = await fetch('/api/event-log?limit=200', { headers: authHeaders });
+        const res = await fetch('/api/event-log?limit=200', { headers: authHeadersRef.current });
         ...
       }
     };
     if (!isHeadlessMode) {
       void load();
     }
     return () => { cancelled = true; };
-  }, [authHeaders, isHeadlessMode]);
+  }, [isHeadlessMode]);

534-535: Avoid as any — define a response type instead.

These casts (repeated at lines 572–573 in handleLoadOlder) violate the project's TypeScript guidelines. A minimal interface eliminates all four occurrences:

♻️ Suggested type
interface EventLogResponse {
  entries?: unknown[];
  nextBeforeSeq?: number;
}

Then use:

-      const entries: unknown = (payload as any)?.entries;
-      const nextBeforeSeq: unknown = (payload as any)?.nextBeforeSeq;
+      const { entries, nextBeforeSeq } = payload as EventLogResponse;

As per coding guidelines, "Enable TypeScript strict mode, declare explicit types, and avoid any".


595-621: downloadingLogs in the dependency array is unnecessary and causes extra re-renders.

The guard on line 596 (if (downloadingLogs) return) already prevents concurrent calls. Including the state variable in the deps array creates a new callback reference whenever it toggles, which can trigger unnecessary re-renders of child components receiving this callback.

♻️ Suggested fix
-  }, [authHeaders, downloadingLogs]);
+  }, [authHeaders]);
llm/context/NIP46_IMPLEMENTATION.md (3)

64-65: Clarify the distinction between schema and runtime behavior.

The note states "revoked remains in the schema for compatibility, but revoked sessions are deleted." This might confuse readers. Consider rewording to make it clearer that the enum value exists in the schema definition, but when a session is revoked at runtime, the row is deleted rather than marked as revoked.

📝 Suggested clarification
 Notes:
-- `revoked` remains in the schema for compatibility, but revoked sessions are deleted and excluded from listings.
+- The `revoked` status value remains in the schema CHECK constraint for compatibility, but when a session is revoked at runtime, the row is deleted from the database rather than being marked with status='revoked'.

187-187: Consider splitting for clarity.

The parenthetical remark makes this sentence dense. Consider splitting into two statements to clarify the state transition flow.

📝 Suggested rewording
-4. Server creates a session with status `pending` (marked `active` on connect or activity)
+4. Server creates a session with status `pending`. The session is marked `active` upon first connect or activity.

379-381: Consider adding tuning guidance.

Based on learnings, timeout and rate limit parameters like FROSTR_SIGN_TIMEOUT should be tuned per environment (development, staging, production). Consider adding a note recommending operators adjust these values based on their specific requirements and load patterns.

📝 Suggested addition
 | Variable | Default | Description |
 |----------|---------|-------------|
 | `FROSTR_SIGN_TIMEOUT` | `30000` | Timeout for signing operations (ms) |
 | `NIP46_SESSION_RATE_LIMIT_MAX` | `120` | Max session creates per window |
 | `NIP46_SESSION_RATE_LIMIT_WINDOW` | `3600` | Rate limit window in seconds |
+
+**Note:** Adjust timeout and rate limit values based on your environment's requirements and expected load patterns.

Based on learnings: "Tune FROSTR_SIGN_TIMEOUT, SIGN_TIMEOUT_MS, AUTH_DERIVED_KEY_TTL_MS, and AUTH_DERIVED_KEY_MAX_READS per environment"

Comment thread docs/AUTH_MATRIX.md
Comment thread frontend/components/Signer.tsx Outdated
Comment thread src/routes/update.ts
…ogs now calls it after resetting state so oldestSeq gets repopulated and the 'load older' button works again
@AustinKelsay

Copy link
Copy Markdown
Member Author

@CodeRabbit full review

@coderabbitai

coderabbitai Bot commented Feb 10, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
llm/context/NIP46_IMPLEMENTATION.md (1)

386-388: ⚠️ Potential issue | 🟡 Minor

Missing language specifier on fenced code block.

This code block at line 386 lacks a language tag, similar to the issue previously fixed at line 261.

✏️ Suggested fix
-```
+```text
 wss://relay.primal.net
</details>

</blockquote></details>
<details>
<summary>env.example (1)</summary><blockquote>

`244-263`: _⚠️ Potential issue_ | _🟡 Minor_

**Example `SESSION_SECRET` values don't match the documented 64-hex-char requirement.**

Lines 91–95 document that `SESSION_SECRET` must be 64 hex chars, but the example values here (e.g., `personal-session-secret-32chars`) are neither hex nor 64 characters. Since these are commented-out illustrative examples, this is low priority, but could confuse users who copy-paste them.

</blockquote></details>
<details>
<summary>frontend/components/Signer.tsx (1)</summary><blockquote>

`388-403`: _⚠️ Potential issue_ | _🟡 Minor_

**Auth credentials in WebSocket URL query parameters are visible in server logs and browser history.**

Passing `apiKey` and `sessionId` as URL query params (lines 392–395) is a common trade-off for WebSocket auth, but these values will appear in server access logs, reverse proxy logs, and potentially browser history. Ensure server-side logging sanitizes query parameters containing auth tokens, or consider using a short-lived ticket/token exchange pattern where the client first obtains a one-time WebSocket ticket via an authenticated HTTP call, then passes only that ticket in the URL.

</blockquote></details>

</blockquote></details>
🤖 Fix all issues with AI agents
In `@llm/implementation/credential-storage-implementation.md`:
- Around line 14-21: The Markdown list under "Data Model" has top-level bullets
for "Encrypted fields:" and "Plaintext fields:" but their child items are not
indented, so they render as siblings; update the block so that the lines
describing `group_cred_encrypted`, `share_cred_encrypted`, `relays`,
`group_name`, `salt`, and `password_hash` are indented (e.g., two spaces or a
tab) under their respective parent labels ("Encrypted fields:" and "Plaintext
fields:") so they become nested list items beneath those headings.

In `@package.json`:
- Line 3: Update the documentation to match the released package version: find
the text "version: 1.1.0" in umbrel-implementation.md and change it to "version:
1.1.1" so it matches the package.json "version": "1.1.1" and the OpenAPI specs;
ensure there are no other stale occurrences of 1.1.0 in that document and commit
the update.

In `@src/server.ts`:
- Around line 218-223: The persistUiEventLogEntry hook is typed with a loose
data?: any and an entry cast to any when calling it; change its type to match
UiEventLogStreamEntry from ./db/ui-event-log.js (which uses data: unknown) by
updating the declaration of persistUiEventLogEntry to ((entry:
UiEventLogStreamEntry) => number | null) | null and adjust createAddServerLog's
persist callback signature accordingly, then remove the entry as any cast
wherever you call persistUiEventLogEntry (refer to the call site that currently
casts entry and to the createAddServerLog initialization) so all types align
with UiEventLogStreamEntry and eliminate any usage.

In `@tests/routes/relay-probe.spec.ts`:
- Around line 1-4: Move the spec file tests/routes/relay-probe.spec.ts into the
same directory as the backend route/module it tests (co-locate the test with the
implementation) and keep the filename ending in .spec.ts (e.g.,
relay-probe.spec.ts next to the route file). After moving, update relative
imports in the test (such as runRouteScript, pathToFileURL and bun:test imports)
to use correct paths from the new location and ensure any test helper import
('./helpers/script-runner') points to the project-level helper using the proper
relative path. Run the test suite to confirm the new location and updated
imports (and adjust any module resolution config if needed).
🧹 Nitpick comments (18)
package.json (1)

36-37: typecheck and tsc scripts are identical.

Both run tsc --noEmit. Consider keeping only one (e.g., typecheck) and removing the duplicate, or having tsc delegate to typecheck if both names are needed for discoverability.

docs/RELEASE.md (1)

37-40: Hard-coded version in release examples will drift.

The branch name and PR reference are pinned to v1.1.1. Consider using a placeholder like v<x.y.z> so the guide doesn't need updating each release, or add a note that these are examples.

docs/CONFIG.md (1)

120-124: Tighten the sentence for DB_PATH definitions.

DB_PATH can be either:” reads like a fragment. Consider folding the bullets into a full sentence for smoother docs flow.

✍️ Suggested wording
-`DB_PATH` can be either:
-- A directory (e.g., `/var/lib/igloo/data`), or
-- A file path (e.g., `/var/lib/igloo/igloo.db`)
+`DB_PATH` can be either a directory (e.g., `/var/lib/igloo/data`) or a file path (e.g., `/var/lib/igloo/igloo.db`).
llm/implementation/node-lifecycle-implementation.md (1)

67-77: Vary repeated sentence starts in the monitoring bullets.

If you want to smooth readability, rephrase a couple of “Monitoring …” bullets to avoid repetition (e.g., “It also enforces…”, “It evaluates…”).

src/node/manager.ts (3)

1338-1366: Timestamp format inconsistency between WS stream and internal events.

The log entry now uses ISO 8601 (new Date().toISOString()) for the broadcast stream (line 1339), while new Date().toLocaleTimeString() is still used for console output (lines 1361, 1364). This is fine and intentional per the comment. However, note that the peer-status-internal broadcast at line 1572 still uses new Date().toLocaleTimeString() for its timestamp field, creating a format inconsistency in the WS stream between regular log events (ISO) and peer-status events (locale).

Consider aligning the peer-status broadcast to ISO as well for consistent client-side parsing:

               broadcastEvent({
                 type: 'peer-status-internal',
                 message: '',
                 data: { ... },
-                timestamp: new Date().toLocaleTimeString(),
+                timestamp: new Date().toISOString(),
                 id: Math.random().toString(36).substring(2, 11)
               });

1500-1514: Indentation is inconsistent within the message handler.

Lines 1500–1514 are indented with extra spaces relative to the surrounding block. The if (typeof tag === 'string') block and the tag-skip guard are indented deeper than expected for their nesting level, while subsequent lines (e.g., 1517 onwards) revert to a different indentation. This makes the control flow harder to follow.


1321-1327: data?: any in the persist callback parameter type.

The data field in the persist callback's entry parameter is typed as any. While this matches the existing addServerLog signature where data is untyped, the coding guidelines recommend avoiding any. Since the log data can genuinely be anything (errors, objects, primitives), unknown would be more type-safe here, though it would require downstream changes. Flagging as optional since it's consistent with the existing pattern.

src/routes/update.ts (2)

240-250: Concurrent requests can trigger duplicate GitHub fetches.

If multiple requests hit /api/update simultaneously while the cache is invalid, each will independently call fetchLatestVersion(). This isn't a correctness bug but could cause unnecessary GitHub API calls and potential rate-limiting.

A simple mitigation is a "pending promise" pattern — store the in-flight promise and let subsequent callers await the same one.


54-58: Extract boolean-from-string parsing into a shared utility function.

The parseBoolean function (lines 54–58) duplicates the same pattern repeated across src/const.ts for HEADLESS, SKIP_ADMIN_SECRET_VALIDATION, SKIP_RELAY_PROBE, DEFER_RELAY_PROBE, and SKIP_STARTUP_ECHO, as well as in src/node/manager.ts for HEADLESS_MODE. Consolidate this logic into a single exported utility (e.g., src/utils/parse-boolean.ts or add to src/const.ts) to prevent drift and reduce duplication.

tests/routes/headless-optimizations.spec.ts (1)

7-15: afterEach cleanup is defensive but not strictly needed for subprocess-based tests.

Since runRouteScript spawns a separate Bun process, the parent's process.env mutations don't affect test isolation. The cleanup is still reasonable as a safety net if future tests operate in-process.

tests/routes/static-imports.spec.ts (1)

1-3: Test file location doesn't follow the co-location guideline.

Per project guidelines, tests should be co-located with their subjects (e.g., src/routes/static-imports.spec.ts). However, this appears to be an established pattern in the repo with a tests/routes/ directory, so flagging as a nit only.

As per coding guidelines, "Co-locate tests with code as *.spec.ts or *.test.ts files" and "Prefer unit tests co-located with their subjects; name *.test.ts or *.spec.ts".

src/const.ts (1)

95-125: Extract a shared helper for boolean env parsing to reduce duplication.

The same 6-line IIFE pattern (read → trim → empty-check → lowercase → match true/1/yes) is repeated for HEADLESS, SKIP_ADMIN_SECRET_VALIDATION, SKIP_RELAY_PROBE, DEFER_RELAY_PROBE, and SKIP_STARTUP_ECHO. A small helper would DRY this up.

♻️ Proposed refactor

Add a helper at the top of the file:

function parseBoolEnv(key: string): boolean {
  const value = process.env[key];
  if (!value) return false;
  const trimmed = value.trim();
  if (trimmed.length === 0) return false;
  const normalized = trimmed.toLowerCase();
  return normalized === 'true' || normalized === '1' || normalized === 'yes';
}

Then replace each IIFE:

-export const SKIP_RELAY_PROBE = (() => {
-  const value = process.env['SKIP_RELAY_PROBE'];
-  if (!value) return false;
-  const trimmed = value.trim();
-  if (trimmed.length === 0) return false;
-  const normalized = trimmed.toLowerCase();
-  return normalized === 'true' || normalized === '1' || normalized === 'yes';
-})();
+export const SKIP_RELAY_PROBE = parseBoolEnv('SKIP_RELAY_PROBE');

Same for DEFER_RELAY_PROBE, SKIP_STARTUP_ECHO, HEADLESS, and SKIP_ADMIN_SECRET_VALIDATION.

src/db/ui-event-log.test.ts (1)

37-37: Multiple as any casts could be replaced with a typed assertion.

Since getBlob returns { data: unknown; byteLength: number } | null, you could narrow with a non-null assertion and type the data field more precisely, avoiding any.

Example for line 37:

const blob = store.getBlob(e1.dataHash!)!;
expect(blob.data).toEqual(commonData);

As per coding guidelines, "Enable TypeScript strict mode, declare explicit types, and avoid any".

Also applies to: 57-59, 76-78

src/db/ui-event-log.ts (2)

1-11: unknown | null is redundant — unknown already includes null.

On lines 8, 22, 38 (and later at 311, 385), the type unknown | null is equivalent to just unknown. If the intent is to signal explicit nullability, consider using a concrete type (e.g., Record<string, unknown> | null) or just unknown.

Also applies to: 13-24, 26-39


191-203: Missing explicit return type on createUiEventLogStore.

The factory function's return type is inferred. Per the coding guidelines ("explicit types, avoid any"), consider declaring an explicit interface for the store object returned by this function.

Suggested approach
+interface UiEventLogStore {
+  append(entry: UiEventLogStreamEntry): { seq: number; dataHash: string | null };
+  list(opts?: { limit?: number; beforeSeq?: number; types?: string[] }): UiEventLogListResult;
+  getBlob(hash: string): { data: unknown; byteLength: number } | null;
+  exportChunk(opts?: { afterSeq?: number; untilSeq?: number; limit?: number; types?: string[] }): { rows: UiEventLogExportRow[]; nextAfterSeq: number | null };
+}
+
-export function createUiEventLogStore(dbConn: Database) {
+export function createUiEventLogStore(dbConn: Database): UiEventLogStore {

As per coding guidelines, "TypeScript strict mode; explicit types, avoid any".

frontend/components/Signer.tsx (3)

522-550: loadInitialHistory uses authHeaders directly — stale closure possible if headers change before the fetch resolves.

loadInitialHistory captures authHeaders from its useCallback dependency. Since it's also called from handleClearLogs (which depends on loadInitialHistory), if authHeaders changes between the clear and the fetch, the request uses the old headers. This is low-risk since auth headers change infrequently, but using the ref (authHeadersRef.current) would be more consistent with the WebSocket approach.


530-533: Use a typed response interface instead of as any casts.

Lines 532–533 cast the response payload to any to access .entries and .nextBeforeSeq. A simple interface would satisfy the "avoid any" guideline and improve type safety.

Suggested approach
+interface EventLogResponse {
+  entries?: unknown[];
+  nextBeforeSeq?: number | null;
+}
+
       const payload = await res.json();
-      const entries: unknown = (payload as any)?.entries;
-      const nextBeforeSeq: unknown = (payload as any)?.nextBeforeSeq;
+      const { entries, nextBeforeSeq } = payload as EventLogResponse;

Apply the same pattern to handleLoadOlder at lines 571–572.

As per coding guidelines, "TypeScript strict mode; explicit types, avoid any".


150-150: checkServerStatus closes over authHeaders from props but also reads stale isSignerRunning via closure.

checkServerStatus depends on isSignerRunning (line 304), so it re-creates on every state change. However, line 277 uses authHeaders from the outer scope (props), not the ref. Since the useEffect at line 318 re-subscribes on every checkServerStatus change, this creates a new interval every time isSignerRunning toggles. This is functional but causes unnecessary interval churn. Consider using a ref for isSignerRunning inside the callback to keep the dependency array stable.

Also applies to: 274-304

Comment thread llm/implementation/credential-storage-implementation.md Outdated
Comment thread package.json Outdated
Comment thread src/server.ts Outdated
Comment thread tests/routes/relay-probe.spec.ts
@AustinKelsay

Copy link
Copy Markdown
Member Author

@CodeRabbit full review

@coderabbitai

coderabbitai Bot commented Mar 5, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Full review triggered.

@AustinKelsay

Copy link
Copy Markdown
Member Author

@CodeRabbit full review

@coderabbitai

coderabbitai Bot commented Mar 5, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (7)
frontend/components/nip46/QRScanner.tsx (1)

47-56: ⚠️ Potential issue | 🟠 Major

Prevent scanner freeze when onResult throws.

At Line 51, if onResultRef.current(result.data) throws, Lines 52-56 are skipped, leaving scanningRef.current = true and blocking future scans.

🐛 Suggested hardening
           (result: QrScanner.ScanResult) => {
             if (scanningRef.current) return
             if (result.data && result.data.startsWith('nostrconnect://')) {
               scanningRef.current = true
-              onResultRef.current(result.data)
-              scannerRef.current?.destroy()
-              scannerRef.current = null
-              timeoutRef.current = setTimeout(() => {
-                scanningRef.current = false
-              }, 1000)
+              try {
+                onResultRef.current(result.data)
+              } catch (callbackError) {
+                const e = callbackError instanceof Error ? callbackError : new Error('QR result handler failed')
+                onErrorRef.current?.(e)
+                setError(e.message)
+              } finally {
+                scannerRef.current?.destroy()
+                scannerRef.current = null
+                timeoutRef.current = setTimeout(() => {
+                  scanningRef.current = false
+                }, 1000)
+              }
             }
           },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/nip46/QRScanner.tsx` around lines 47 - 56, The QR scan
handler can leave scanningRef.current stuck true if
onResultRef.current(result.data) throws; update the handler inside the
QrScanner.ScanResult callback to call onResultRef.current(result.data) inside a
try/finally (awaiting it via Promise.resolve to handle sync or async errors) and
perform the cleanup in the finally block: destroy scannerRef.current, set
scannerRef.current = null, and set/reset timeoutRef.current to flip
scanningRef.current back to false; also clear any previous timeoutRef before
assigning a new one. Refer to scanningRef, onResultRef, scannerRef, timeoutRef
and the QrScanner.ScanResult callback when making the change.
frontend/components/ui/input-with-validation.tsx (1)

5-12: ⚠️ Potential issue | 🟡 Minor

Remove trailing whitespace on line 55.

Line 55 has trailing whitespace after the semicolon in the export statement. All existing usages of InputWithValidation correctly pass the isRequired prop, so the required prop declaration is appropriate and not a breaking change.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/ui/input-with-validation.tsx` around lines 5 - 12, Remove
the trailing whitespace after the semicolon in the export statement for the
InputWithValidation component/interface (the export referring to
InputWithValidation or InputWithValidationProps) so the file has no trailing
spaces; locate the export that references InputWithValidation (or the
InputWithValidationProps interface) and delete the extra space character after
the semicolon to fix the lint/trailing-whitespace issue.
src/routes/peers.ts (1)

794-811: ⚠️ Potential issue | 🟡 Minor

Remove as any casts in ping result checks.

This violates the TypeScript strict typing requirement. There are two instances (lines 810 and 876) — use type narrowing instead.

♻️ Suggested typed check
-        if ((result as any).ok) {
+        const pingResult = result as { ok?: unknown };
+        if (pingResult.ok === true) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/peers.ts` around lines 794 - 811, The ping result checks currently
use unsafe "as any" casts; create a proper type guard (e.g.,
isPingResponse(obj): obj is PingResponse) that verifies result is an object and
has an ok boolean property, then replace the two uses of "(result as any).ok"
(inside the mapWithConcurrency callback where result comes from
context.node.req.ping, and the second occurrence around line 876) with a guarded
check like "if (isPingResponse(result) && result.ok)". Add a PingResponse
interface (ok: boolean, plus any other expected fields) and reuse the
isPingResponse guard wherever ping results are inspected to satisfy TypeScript
strict typing.
frontend/components/Configure.tsx (1)

119-125: ⚠️ Potential issue | 🟡 Minor

Prevent stale aborted requests from clearing active loading state.

When a new request to loadAdvancedSettings is initiated, it overwrites loadAdvancedSettingsRef.current with a new AbortController, causing the previous request to be aborted. However, the aborted request's finally block (line 173) still executes and clears isLoadingAdvanced to false, even though the new request is still in flight. This creates a race condition where the loading indicator disappears prematurely.

The early return in the catch block (line 167) prevents error handling but does not prevent the finally block from executing. To fix this, store the controller in a local variable and only clear the loading state if the current ref still matches that controller:

Proposed fix
-    loadAdvancedSettingsRef.current = new AbortController();
-    const signal = loadAdvancedSettingsRef.current.signal;
+    const controller = new AbortController();
+    loadAdvancedSettingsRef.current = controller;
+    const signal = controller.signal;
...
-    } finally {
-      setIsLoadingAdvanced(false);
+    } finally {
+      if (loadAdvancedSettingsRef.current === controller) {
+        setIsLoadingAdvanced(false);
+      }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/Configure.tsx` around lines 119 - 125, The race condition
occurs in loadAdvancedSettings: when you replace loadAdvancedSettingsRef.current
with a new AbortController the previous request's finally block can still clear
isLoadingAdvanced even though a newer request is active; fix it by creating a
local const controller = new AbortController() and assigning it to
loadAdvancedSettingsRef.current, use controller.signal for the fetch, and in the
finally block only set isLoadingAdvanced(false) if
loadAdvancedSettingsRef.current === controller (i.e., the controller is still
the current one); ensure catch still handles AbortError appropriately but does
not rely on early returns to prevent finally from running.
frontend/components/Onboarding.tsx (1)

95-97: ⚠️ Potential issue | 🟠 Major

Skip-admin mode can still be blocked by the admin-secret gate.

skipAdminValidation is set from status, but the later !hasAdminSecret early return still blocks onboarding in skip-admin deployments.

Suggested fix
-  if (!hasAdminSecret) {
+  if (!hasAdminSecret && !skipAdminValidation) {
     return (
       <PageLayout>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/Onboarding.tsx` around lines 95 - 97, The onboarding flow
sets skipAdminValidation via setSkipAdminValidation in the success branch
(skipAdminValidation), but the component still returns early when
!hasAdminSecret, preventing skip-admin deployments from proceeding; update the
early-return logic that checks hasAdminSecret (or wherever hasAdminSecret is
used to block rendering) to also allow continuation when skipAdminValidation is
true (i.e., treat skipAdminValidation as bypass of the admin-secret gate),
ensuring the UI/render path uses the skipAdminValidation state variable instead
of always blocking on hasAdminSecret.
src/routes/env.ts (1)

727-729: ⚠️ Potential issue | 🟠 Major

Deleting RELAYS does not reconfigure the running node.

When only RELAYS is deleted, .env/process.env are updated, but node restart/cleanup is skipped. That leaves active relay connections stale until another restart event.

💡 Proposed fix
-          const deletingCredentials = validKeys.some(key => ['GROUP_CRED', 'SHARE_CRED'].includes(key));
+          const deletingCredentials = validKeys.some((key) => ['GROUP_CRED', 'SHARE_CRED'].includes(key));
+          const deletingRelays = validKeys.includes('RELAYS');
...
-            if (deletingCredentials) {
+            if (deletingCredentials) {
               try {
                 // Use synchronized cleanup to prevent race conditions
                 await cleanupNodeSynchronized(context);
               } catch (error) {
                 // Error already logged by executeUnderNodeLock
                 // Continue anyway - the env vars were deleted
               }
+            } else if (deletingRelays) {
+              const canRecreateNode =
+                typeof env.GROUP_CRED === 'string' &&
+                env.GROUP_CRED.length > 0 &&
+                typeof env.SHARE_CRED === 'string' &&
+                env.SHARE_CRED.length > 0;
+              if (canRecreateNode) {
+                try {
+                  await executeUnderNodeLock(async () => {
+                    await createAndConnectServerNode(env, context);
+                  }, context);
+                } catch (error) {
+                  return Response.json(
+                    { success: false, message: 'Failed to recreate Bifrost node after RELAYS deletion' },
+                    { status: 500, headers }
+                  );
+                }
+              }
             }

Also applies to: 735-754

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/env.ts` around lines 727 - 729, The code only sets
deletingCredentials from validKeys.includes(['GROUP_CRED','SHARE_CRED']) and
therefore skips the restart/cleanup path when RELAYS is removed; update the
deletion detection so RELAYS triggers the same reconfiguration flow: modify the
check around deletingCredentials (and/or add a deletingRelays boolean) to
include 'RELAYS' (e.g., ['GROUP_CRED','SHARE_CRED','RELAYS']) or OR the new flag
into the condition that runs the node restart/cleanup logic, and ensure the
branch that performs the restart/cleanup (the same flow currently executed for
deletingCredentials in the block covering lines ~735-754) is executed when
RELAYS is deleted so active relay connections are properly cleaned up and
reconfigured.
src/nip46/service.ts (1)

211-212: ⚠️ Potential issue | 🟠 Major

Don’t swallow startup failures in ensureStarted().

ensureStarted() logs and resolves even when startInternal() fails; callers can continue with a non-running agent.

Suggested patch
   setActiveUser(userId: number | bigint | null) {
@@
     if (userId == null) {
       void this.stop()
     } else {
-      void this.ensureStarted()
+      void this.ensureStarted().catch((error) => {
+        this.log('error', 'Failed to start NIP-46 service', { error: this.serializeError(error) })
+      })
     }
   }
@@
     this.startingPromise = (async () => {
       try {
         await this.startInternal()
       } catch (error) {
         this.log('error', 'Failed to start NIP-46 service', { error: this.serializeError(error) })
+        throw error
       } finally {
         this.startingPromise = null
       }
     })()

Also applies to: 223-231

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nip46/service.ts` around lines 211 - 212, The current ensureStarted()
implementation catches errors from startInternal(), logs them and still
resolves, which lets callers proceed with a non-running agent; change
ensureStarted() to let startInternal() failures propagate (or rethrow after
logging) so the returned Promise rejects on startup failure, and apply the same
change to the similar startup block around the start/startInternal usage (the
other block referencing startInternal at lines 223-231) so neither path swallows
errors; ensure you reference and modify the ensureStarted and startInternal call
sites and update any tests/callers that assumed silent failures.
♻️ Duplicate comments (8)
frontend/components/ui/log-entry.tsx (1)

112-118: ⚠️ Potential issue | 🟡 Minor

Handle missing data in successful blob responses as an explicit failure.

The success branch still silently no-ops when payload lacks data; this should set an error state so the UI can communicate failure.

Suggested fix
       .then(payload => {
         if (cancelled) return;
         if (payload && typeof payload === 'object' && 'data' in payload) {
           setResolvedData((payload as any).data);
           setHasFetchedFull(true);
+        } else {
+          setFetchError('failed_to_load_payload');
         }
       })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/ui/log-entry.tsx` around lines 112 - 118, In the promise
.then handler, explicitly treat a successful payload that lacks a data property
as a failure: after the cancelled check and the existing branch that calls
setResolvedData(...) and setHasFetchedFull(true), add the else branch that sets
an error state (e.g. call your component's error setter like setBlobFetchError
or setFetchError with a descriptive Error or message) and ensure
setHasFetchedFull(false) (and clear any partial resolved data if needed) so the
UI can show the failure; keep using the same symbols shown (payload, cancelled,
setResolvedData, setHasFetchedFull) to locate where to add the error handling.
frontend/components/Signer.tsx (1)

535-536: ⚠️ Potential issue | 🟠 Major

Replace payload as any with typed narrowing in both history loaders.

Lines 535-536 and 574-575 bypass strict typing and can hide malformed server payloads at runtime.

🔧 Suggested change
+type EventLogPayload = {
+  entries?: unknown;
+  nextBeforeSeq?: unknown;
+};
+
+const asEventLogPayload = (value: unknown): EventLogPayload =>
+  value && typeof value === 'object' ? (value as EventLogPayload) : {};
+
       const payload = await res.json();
-      const entries: unknown = (payload as any)?.entries;
-      const nextBeforeSeq: unknown = (payload as any)?.nextBeforeSeq;
+      const { entries, nextBeforeSeq } = asEventLogPayload(payload);
#!/bin/bash
rg -nP "\(payload as any\)\?\.(entries|nextBeforeSeq)" frontend/components/Signer.tsx -C2

As per coding guidelines, "**/*.{ts,tsx}: TypeScript strict mode; explicit types, avoid any."

Also applies to: 574-575

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/Signer.tsx` around lines 535 - 536, The code bypasses
TypeScript strictness by using "payload as any" for entries and nextBeforeSeq;
replace those casts with a narrow type-check: add a type/interface (e.g.,
HistoryPayload { entries: unknown[]; nextBeforeSeq?: number | string | null })
and a type guard function (e.g., isHistoryPayload(payload): payload is
HistoryPayload) that validates Array.isArray(payload.entries) and the optional
nextBeforeSeq shape, then in both history loader blocks use the guard to safely
extract const entries = payload.entries and const nextBeforeSeq =
payload.nextBeforeSeq only when isHistoryPayload(payload) returns true (handle
the else branch by returning a safe default or throwing). Ensure you update the
two sites that reference entries and nextBeforeSeq (the history loader
variables) to use this typed narrowing instead of "payload as any".
docs/AUTH_MATRIX.md (1)

17-17: ⚠️ Potential issue | 🟡 Minor

Clarify /api/update behavior in headless/managed modes.

This row currently says the endpoint is available (Yes) while the note says update checks are disabled in those same modes. Please state explicit behavior (e.g., “endpoint exists but returns disabled status/payload”) to remove ambiguity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/AUTH_MATRIX.md` at line 17, Update the `/api/update` entry in
AUTH_MATRIX.md to remove ambiguity by changing the availability cell from "Yes"
to a clear statement like "Yes — endpoint exists but returns a
disabled/unavailable response in managed/headless modes" and adjust the note to
explicitly state that while the route is present, update checks are disabled
when HEADLESS=true, SKIP_ADMIN_SECRET_VALIDATION=true, or
UPDATE_CHECK_DISABLED=true (so consumers know the endpoint will respond with a
disabled status/payload rather than performing update checks).
src/routes/update.ts (3)

129-137: ⚠️ Potential issue | 🔴 Critical

Fix HeadersInit mutation typing to satisfy strict TypeScript.

Line 136 mutates a HeadersInit union with string indexing, which is not type-safe in strict mode.

Suggested patch
-function buildHeaders(): HeadersInit {
-  const headers: HeadersInit = {
+function buildHeaders(): Record<string, string> {
+  const headers: Record<string, string> = {
     'Accept': 'application/vnd.github+json',
     'User-Agent': 'igloo-server'
   };
   const token = process.env['GITHUB_TOKEN']?.trim();
   if (token) {
     headers['Authorization'] = `Bearer ${token}`;
   }
   return headers;
 }
As per coding guidelines, "TypeScript strict mode; explicit types, avoid `any`".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/update.ts` around lines 129 - 137, The buildHeaders function
mutates a HeadersInit union via string indexing which fails in strict TS; change
the implementation to use a concrete mutable type (e.g., create a
Record<string,string> or a Headers instance) so you can safely add
Authorization. In practice, inside buildHeaders replace the current headers
variable with a mutable object type (Record<string,string>) or instantiate new
Headers(), set the Accept and User-Agent, then conditionally add the
Authorization header using the safe setter (e.g., headers['Authorization'] = ...
for Record or headers.set(...) for Headers) and return it as HeadersInit;
reference function name buildHeaders and the Authorization assignment site to
locate the change.

222-223: ⚠️ Potential issue | 🟡 Minor

Include an Allow header in the 405 response.

The method-not-allowed branch should advertise allowed methods (GET, OPTIONS).

Suggested patch
   if (req.method !== 'GET') {
-    return Response.json({ error: 'Method not allowed' }, { status: 405, headers });
+    return Response.json(
+      { error: 'Method not allowed' },
+      { status: 405, headers: { ...headers, Allow: 'GET, OPTIONS' } }
+    );
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/update.ts` around lines 222 - 223, The 405 branch in the request
handler currently returns Response.json without an Allow header; update the
branch that checks if (req.method !== 'GET') to include an 'Allow' header set to
'GET, OPTIONS' when creating the Response (reuse or extend the existing headers
object used in this file), so the Response.json call for the Method Not Allowed
path advertises allowed methods; ensure the header key is exactly 'Allow' and
returned together with status 405.

117-120: ⚠️ Potential issue | 🟡 Minor

Prerelease comparison is lexicographic, not SemVer precedence-aware.

localeCompare can misorder versions like rc.10 vs rc.9, which can flip updateAvailable.

Suggested patch
+function comparePrerelease(a: string, b: string): number {
+  const aParts = a.split('.');
+  const bParts = b.split('.');
+  const len = Math.max(aParts.length, bParts.length);
+  for (let i = 0; i < len; i++) {
+    const av = aParts[i];
+    const bv = bParts[i];
+    if (av === undefined) return -1;
+    if (bv === undefined) return 1;
+    const aNum = /^\d+$/.test(av);
+    const bNum = /^\d+$/.test(bv);
+    if (aNum && bNum) {
+      const diff = Number(av) - Number(bv);
+      if (diff !== 0) return diff;
+      continue;
+    }
+    if (aNum && !bNum) return -1;
+    if (!aNum && bNum) return 1;
+    if (av !== bv) return av < bv ? -1 : 1;
+  }
+  return 0;
+}
+
 function compareVersions(a: ParsedVersion, b: ParsedVersion): number {
@@
-  if (a.prerelease && b.prerelease) return a.prerelease.localeCompare(b.prerelease);
+  if (a.prerelease && b.prerelease) return comparePrerelease(a.prerelease, b.prerelease);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/update.ts` around lines 117 - 120, The prerelease comparison using
a.prerelease.localeCompare(b.prerelease) is lexicographic and misorders
identifiers like "rc.10" vs "rc.9"; replace that comparison with a SemVer-aware
comparison (e.g., use the semver package to compare full versions or compare
prerelease identifier arrays) so numeric identifier segments are compared
numerically and alphanumeric segments lexically; specifically, in the block that
currently checks a.prerelease and b.prerelease, call semver.compare(a.version,
b.version) (or parse semver.prerelease(...) and compare segments numerically
then lexically) instead of localeCompare to determine precedence.
src/routes/index.ts (1)

222-224: ⚠️ Potential issue | 🟡 Minor

/api/update is public, which exposes version/deployment metadata to unauthenticated callers.

Line 223 puts this route in publicEndpoints, so exact version data is available even when auth is enabled.

Suggested patch (strictest option)
   const publicEndpoints = [
@@
-    '/api/onboarding/setup',
-    '/api/update'
+    '/api/onboarding/setup'
   ];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/index.ts` around lines 222 - 224, The route '/api/update' is
currently listed in publicEndpoints, exposing version/deployment metadata to
unauthenticated callers; remove '/api/update' from the publicEndpoints array (or
move it to a protectedRoutes list) and ensure the update handler (the
route/controller that serves '/api/update') enforces
authentication/authorization before returning any sensitive metadata, or
alternatively create a non-sensitive public summary endpoint and keep detailed
version info behind auth; update any middleware that uses publicEndpoints to
stop skipping auth for '/api/update'.
docs/openapi/openapi.json (1)

164-236: ⚠️ Potential issue | 🟡 Minor

/api/events still documents unconditional auth via inherited global security.

The operation description says auth is conditional (AUTH_ENABLED=true), but without an explicit override it still inherits global security. Add a route-level override (and public marker) to match runtime behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/openapi/openapi.json` around lines 164 - 236, The /api/events GET
operation (operationId "eventsWebSocket") currently inherits global security but
is conditionally public at runtime; modify that operation to explicitly override
global security by adding an empty security array (e.g. "security": []) and add
a public marker (e.g. vendor extension "x-public": true) to the GET operation
object so the OpenAPI doc matches runtime behavior when AUTH_ENABLED is false.
🟡 Minor comments (24)
frontend/components/nip46/RelaySettings.tsx-37-43 (1)

37-43: ⚠️ Potential issue | 🟡 Minor

Add explicit return type to handleRemove.

The async function at line 37 is missing an explicit return type annotation, violating the TypeScript strict mode guideline.

Suggested change
-  const handleRemove = async (relay: string) => {
+  const handleRemove = async (relay: string): Promise<void> => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/nip46/RelaySettings.tsx` around lines 37 - 43, The
function handleRemove is an async handler missing an explicit return type;
update its signature to declare the return type (e.g., Promise<void>) so
TypeScript strict mode is satisfied, keeping the body the same and referencing
the existing onRemove(relay) call and the try/catch block in handleRemove.
frontend/components/ui/modal.tsx-47-51 (1)

47-51: ⚠️ Potential issue | 🟡 Minor

Restore focus to the previously focused element on close.

Line 50 moves focus into the modal, but focus is not restored after close. Keyboard users can lose context.

💡 Suggested fix
   useEffect(() => {
     if (!isOpen) return;
+    const previousActive = document.activeElement as HTMLElement | null;
     // Ensure screen readers and keyboard users land on the active dialog.
     dialogRef.current?.focus();
+    return () => {
+      previousActive?.focus();
+    };
   }, [isOpen]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/ui/modal.tsx` around lines 47 - 51, When opening the
modal in the useEffect that currently focuses dialogRef (useEffect(() => { if
(!isOpen) return; dialogRef.current?.focus(); }, [isOpen])), capture the
currently focused element (e.g., const previousFocus = document.activeElement as
HTMLElement | null) and on cleanup/close restore focus to that saved element
(previousFocus?.focus()) if it still exists; implement this by turning the
effect into one that saves previousFocus when isOpen becomes true and returns a
cleanup function that restores previousFocus when the modal closes, using the
existing dialogRef and isOpen symbols.
frontend/components/Configure.tsx-175-175 (1)

175-175: ⚠️ Potential issue | 🟡 Minor

Stabilize authHeaders identity to prevent repeated reload effects.

Because dependencies at Line 175 and Line 318 track authHeaders, the default object literal (authHeaders = {}) can trigger repeated effect reruns when the prop is omitted, causing avoidable refetch/state churn. While the component is currently always instantiated with an explicit authHeaders prop, using a frozen constant prevents this latent bug.

💡 Proposed fix
+const EMPTY_AUTH_HEADERS: Readonly<Record<string, string>> = Object.freeze({});
+
-const Configure: React.FC<ConfigureProps> = ({ onKeysetCreated, onCredentialsSaved, onBack, authHeaders = {} }) => {
+const Configure: React.FC<ConfigureProps> = ({ onKeysetCreated, onCredentialsSaved, onBack, authHeaders = EMPTY_AUTH_HEADERS }) => {

Also applies to: 318-318

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/Configure.tsx` at line 175, The component's authHeaders
default object causes unstable identity and repeated effect reruns; define a
single frozen constant (e.g. DEFAULT_AUTH_HEADERS = Object.freeze({})) and use
it as the default value for the Configure component's authHeaders prop (or in
defaultProps) so the identity is stable, then ensure all effects that list
authHeaders (the effect referencing authHeaders in Configure and the other
effect that also depends on authHeaders) use that stabilized
DEFAULT_AUTH_HEADERS as their default/initial value.
llm/implementation/umbrel-implementation.md-38-39 (1)

38-39: ⚠️ Potential issue | 🟡 Minor

Update Bun base-image version in documentation to match actual Dockerfile.

Line 38 hardcodes oven/bun:1.1.30, but both packages/umbrel/igloo/Dockerfile and the root Dockerfile use oven/bun:1.3.10. Update the documentation to reflect the current version.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@llm/implementation/umbrel-implementation.md` around lines 38 - 39, The
documentation currently hardcodes the Bun base image tag "oven/bun:1.1.30";
update the mention in umbrel-implementation.md to "oven/bun:1.3.10" so it
matches the actual Dockerfiles, and scan the doc for any other references of
"oven/bun:1.1.30" (e.g., the multi-stage build and runtime stage notes) and
replace them with "oven/bun:1.3.10" to keep the docs and the Dockerfiles
consistent.
src/routes/env.ts-437-437 (1)

437-437: ⚠️ Potential issue | 🟡 Minor

Fix env parameter typing to eliminate the as any cast.

The root issue is the env: any parameter at line 27; this forces downstream workarounds like the cast at line 437 and violates strict typing requirements. Define env as Record<string, any> or a specific interface instead, then remove the redundant as any cast at line 437.

Per coding guidelines: **/*.{ts,tsx}: TypeScript strict mode; explicit types, avoid any.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/env.ts` at line 437, The env parameter is typed as any which
forces the cast (env as any). Change the function signature that declares the
env parameter (the env parameter at its declaration) to use a concrete type such
as Record<string, any> or a dedicated interface describing keys like RELAYS,
then remove the redundant cast when assigning relaysVal (const relaysVal = (env
as any).RELAYS) and use env.RELAYS directly; update any other usages in the same
module to the new type to satisfy strict TypeScript rules.
src/routes/onboarding.ts-270-273 (1)

270-273: ⚠️ Potential issue | 🟡 Minor

Address inconsistent PASSWORD_REGEX definitions across the codebase.

The regex change from [^A-Za-z0-9] to [^A-Za-z0-9\s] correctly excludes whitespace from counting as a special character. This is properly documented in the error message at line 348 ("excluding whitespace") and consistently implemented in both the backend (onboarding.ts) and frontend (Onboarding.tsx).

However, src/config/crypto.ts still defines the old regex pattern without the \s exclusion. This unused definition should be updated to match or removed to avoid confusion, especially since the isPasswordValid() function there would enforce different rules if called elsewhere.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/onboarding.ts` around lines 270 - 273, Update the stale regex in
src/config/crypto.ts so it matches the onboarding PASSWORD_REGEX behavior
(exclude whitespace from counting as a special char) or remove the unused
definition; specifically, ensure the pattern used by the PASSWORD_REGEX in
onboarding.ts (/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9\s]).*$/) is
mirrored in crypto.ts and that any function there (e.g., isPasswordValid) uses
that same pattern to avoid divergent password rules across the codebase.
tests/routes/env.db-mode.spec.ts-10-14 (1)

10-14: ⚠️ Potential issue | 🟡 Minor

Avoid fallback hard-coded key material in test setup.

Line 13 still embeds a literal secret value. Prefer env/fixture-only input so secret handling stays policy-compliant across all TS files.

🔧 Suggested change
 const TEST_KEYSET_SECRET =
   normalizeOptionalEnv(process.env.TEST_KEYSET_SECRET) ??
-  normalizeOptionalEnv(process.env.TEST_NSEC_HEX) ??
-  'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef';
+  normalizeOptionalEnv(process.env.TEST_NSEC_HEX);
+
+if (!TEST_KEYSET_SECRET) {
+  throw new Error('Set TEST_KEYSET_SECRET (or TEST_NSEC_HEX) for env.db-mode.spec.ts');
+}

As per coding guidelines, "Never hard-code secrets; load from environment or data/ fixtures."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/routes/env.db-mode.spec.ts` around lines 10 - 14, The test currently
falls back to a hard-coded secret in the TEST_KEYSET_SECRET assignment; remove
the literal fallback and instead require/tests to source the secret from
environment or a test fixture: replace the fallback branch of the
TEST_KEYSET_SECRET initialization (the use of
normalizeOptionalEnv(process.env.TEST_KEYSET_SECRET) ??
normalizeOptionalEnv(process.env.TEST_NSEC_HEX) ?? 'deadbeef...') so that if
both normalizeOptionalEnv(process.env.TEST_KEYSET_SECRET) and
normalizeOptionalEnv(process.env.TEST_NSEC_HEX) are undefined the test either
throws a clear error instructing to set the env var or reads the secret from a
fixtures loader (e.g., a test fixture loader for test key material), ensuring no
hard-coded secret remains; update any test setup harness that references
TEST_KEYSET_SECRET accordingly.
tests/routes/env.db-mode.spec.ts-117-123 (1)

117-123: ⚠️ Potential issue | 🟡 Minor

process.exit(0) undermines the new cleanup finally block.

Line 117 exits the process immediately after logging, so the cleanup in Lines 118-123 is not guaranteed to run as intended. Let the script return naturally.

🔧 Suggested change
-        console.log('@@RESULT@@' + JSON.stringify({ status, hasStamp }));
-        process.exit(0);
+        console.log('@@RESULT@@' + JSON.stringify({ status, hasStamp }));
+        return;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/routes/env.db-mode.spec.ts` around lines 117 - 123, The test calls
process.exit(0) which aborts execution before the surrounding finally block can
run; remove the process.exit(0) call (or replace it with a normal return) so the
finally section that restores cwd via process.chdir(originalCwd) and removes the
temp dir with fs.rmSync(tmp, { recursive: true, force: true }) always executes;
update the code paths in the same try/catch/finally block (the section around
process.exit(0), process.chdir(originalCwd), and fs.rmSync) to return or let the
function complete normally instead of exiting the process.
tests/routes/static-imports.spec.ts-1-4 (1)

1-4: ⚠️ Potential issue | 🟡 Minor

Co-locate this backend spec with the module under test.

This new route/backend spec is still under tests/routes instead of next to its subject.

As per coding guidelines: **/*.{test,spec}.ts: Co-locate backend tests with code and name them feature.test.ts or feature.spec.ts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/routes/static-imports.spec.ts` around lines 1 - 4, This spec file is in
tests/routes but must be co-located with the module it tests; move and rename
the file next to the module under test using the naming convention
feature.test.ts or feature.spec.ts, update any relative imports (e.g.,
runRouteScript and other imports like afterEach/describe/expect/test and
pathToFileURL) to the new location so they resolve correctly, and ensure any
test runner configuration still picks up the new filename pattern.
src/routes/sign.ts-251-255 (1)

251-255: ⚠️ Potential issue | 🟡 Minor

Handle uppercase 0X prefix when normalizing signatures.

Current normalization only strips lowercase 0x, so 0X... gets rejected even when the signature is otherwise valid.

🛠️ Proposed fix
-    if (typeof signatureHex === 'string' && signatureHex.startsWith('0x')) {
+    if (typeof signatureHex === 'string' && /^0x/i.test(signatureHex)) {
       signatureHex = signatureHex.slice(2);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/sign.ts` around lines 251 - 255, The normalization only removes a
lowercase '0x' prefix so signatures beginning with '0X' are rejected; update the
logic around the signatureHex variable (the startsWith/regex check and
subsequent validation using /^[0-9a-fA-F]{128}$/) to strip either '0x' or '0X'
(e.g., check startsWith('0x') || startsWith('0X') or normalize the prefix to
lowercase before slicing) before running the hex-length regex, ensuring
signatureHex remains a string for the /^[0-9a-fA-F]{128}$/ test.
tests/routes/headless-optimizations.spec.ts-1-4 (1)

1-4: ⚠️ Potential issue | 🟡 Minor

Place this backend spec alongside its subject module.

The new spec is not co-located with the code it validates.

As per coding guidelines: **/*.{test,spec}.ts: Prefer unit tests co-located with their subjects; name *.test.ts or *.spec.ts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/routes/headless-optimizations.spec.ts` around lines 1 - 4, This spec
file (tests/routes/headless-optimizations.spec.ts) should be co-located with the
module it tests: move the file from tests/routes/ to the same directory as the
subject module (rename or place as headless-optimizations.spec.ts alongside that
module), update any relative imports (e.g., runRouteScript and pathToFileURL) to
the new location, and ensure the test filename matches the project glob pattern
(*.test.ts or *.spec.ts) so the runner picks it up; keep the existing test
contents and imports (afterEach, describe, expect, test, runRouteScript) but fix
import paths after relocation.
tests/routes/static-imports.spec.ts-332-349 (1)

332-349: ⚠️ Potential issue | 🟡 Minor

Test intent doesn’t match implementation (server.ts is never imported).

The test name and comments claim server.ts import coverage, but the script only imports src/routes/auth.ts and src/utils/rate-limiter.ts.

♻️ Proposed fix
-    test('server.ts can import auth statically without circular dependency', () => {
+    test('auth.ts and rate-limiter.ts import statically without circular dependency', () => {
...
-        // This tests that the server module can be imported
-        // without circular dependency issues after adding static imports
+        // This tests that auth/rate-limiter static imports do not
+        // introduce circular dependency issues.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/routes/static-imports.spec.ts` around lines 332 - 349, The test name
says it verifies importing server.ts but the script never imports it; update the
test body for "server.ts can import auth statically without circular dependency"
to actually import the server module (e.g., await import(root +
'src/server.ts')) after setting process.env, so the test triggers any
circular-import errors from Server initialization; keep or also await the
existing imports (src/routes/auth.ts and src/utils/rate-limiter.ts) if needed,
and ensure the awaits are inside the try/catch so importError captures failures.
scripts/release.sh-95-95 (1)

95-95: ⚠️ Potential issue | 🟡 Minor

Quote VERSION_TYPE in the npm version invocation.

Line 95 is unquoted, so accidental whitespace/extra tokens in input can alter command behavior.

Suggested patch
-    NEW_VERSION=$(npm version $VERSION_TYPE --no-git-tag-version)
+    NEW_VERSION=$(npm version "$VERSION_TYPE" --no-git-tag-version)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/release.sh` at line 95, The npm version invocation uses an unquoted
shell variable and should quote VERSION_TYPE to avoid word-splitting or
globbing; update the command that assigns NEW_VERSION (the line invoking npm
version with VERSION_TYPE) to pass "$VERSION_TYPE" (preserving the variable name
NEW_VERSION and the npm version call) so the shell treats the entire variable
value as a single argument.
src/routes/admin.ts-139-144 (1)

139-144: ⚠️ Potential issue | 🟡 Minor

Clamp fallback Retry-After to positive seconds.

If RATE_LIMIT_WINDOW is 0/negative, the fallback header can become invalid.

Suggested patch
-    const validatedWindowSeconds = Number.isFinite(windowSecondsRaw) ? windowSecondsRaw : 900;
+    const validatedWindowSeconds =
+      Number.isFinite(windowSecondsRaw) && windowSecondsRaw > 0 ? windowSecondsRaw : 900;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/admin.ts` around lines 139 - 144, The fallback Retry-After can be
zero or negative if RATE_LIMIT_WINDOW is 0/negative; update the logic around
windowSecondsRaw/validatedWindowSeconds/fallbackRetryAfterSeconds to clamp the
fallback to at least 1 second (e.g., compute fallbackRetryAfterSeconds from
Math.max(1, Math.ceil(validatedWindowSeconds))). Keep the existing
retryAfterSeconds branch that uses rate.resetAt unchanged but ensure
fallbackRetryAfterSeconds is always a positive integer string.
src/nip46/service.ts-494-495 (1)

494-495: ⚠️ Potential issue | 🟡 Minor

Guard duplicate lookup when request id is missing.

String(req.id) turns absent ids into "undefined"/"null", which can cause incorrect duplicate suppression.

Suggested patch
-    const existing = getPendingNip46RequestByClientId(this.activeUserId, pubkey, String(req.id))
+    if (req.id == null) {
+      await this.sendSocketResponse(req, pubkey, { error: 'Invalid request id' })
+      return
+    }
+    const requestId = String(req.id)
+    const existing = getPendingNip46RequestByClientId(this.activeUserId, pubkey, requestId)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nip46/service.ts` around lines 494 - 495, The duplicate-request check
currently calls getPendingNip46RequestByClientId(this.activeUserId, pubkey,
String(req.id)), which converts missing req.id into the string
"undefined"/"null" and can incorrectly suppress new requests; update the logic
in the handler around this.activeUserId/pubkey/req.id to first guard for a
present, valid req.id (e.g., if req.id == null or === undefined) and only call
getPendingNip46RequestByClientId when req.id exists, otherwise skip the
duplicate lookup so absent IDs do not match an accidental "undefined"/"null"
key.
src/routes/admin.ts-465-469 (1)

465-469: ⚠️ Potential issue | 🟡 Minor

Method-not-allowed responses should include Allow.

The new known-path 405 branch returns status code correctly, but omits allowed methods metadata.

Suggested patch
+const ALLOWED_METHODS_BY_ADMIN_PATH: Record<string, string> = {
+  '/api/admin/whoami': 'GET, OPTIONS',
+  '/api/admin/users': 'GET, OPTIONS',
+  '/api/admin/users/delete': 'POST, OPTIONS',
+  '/api/admin/api-keys': 'GET, POST, OPTIONS',
+  '/api/admin/api-keys/revoke': 'POST, OPTIONS',
+  '/api/admin/status': 'GET, OPTIONS',
+};
@@
   const isKnownPath = KNOWN_ADMIN_PATHS.has(url.pathname);
   return Response.json(
     { error: isKnownPath ? 'Method not allowed' : 'Not found' },
-    { status: isKnownPath ? 405 : 404, headers }
+    {
+      status: isKnownPath ? 405 : 404,
+      headers: isKnownPath
+        ? { ...headers, Allow: ALLOWED_METHODS_BY_ADMIN_PATH[url.pathname] ?? 'GET, POST, OPTIONS' }
+        : headers
+    }
   );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/admin.ts` around lines 465 - 469, The 405 branch omits the
required Allow header; update the response construction where isKnownPath is
computed (KNOWN_ADMIN_PATHS.has(url.pathname)) and the subsequent Response.json
call to add an "Allow" header listing permitted methods for that path when
isKnownPath is true (e.g., derive allowed methods for the matched admin route
and include headers['Allow'] or merge into the headers object passed to
Response.json) so that Method Not Allowed responses include the Allow metadata.
src/routes/auth.ts-967-980 (1)

967-980: ⚠️ Potential issue | 🟡 Minor

Use limiter reset time for Retry-After in login 429s.

Line 975 currently returns the full configured window, not the actual remaining cooldown from the limiter.

⏱️ Suggested fix
     const rate = await checkRateLimit(req);
     if (!rate.allowed) {
+      const retryAfter = typeof rate.resetAt === 'number' && Number.isFinite(rate.resetAt)
+        ? Math.max(0, Math.ceil((rate.resetAt - Date.now()) / 1000))
+        : Math.ceil(AUTH_CONFIG.RATE_LIMIT_WINDOW / 1000);
       return Response.json(
         { error: 'Too many login attempts. Please try again later.' },
         {
           status: 429,
           headers: {
             ...baseHeaders,
-            'Retry-After': Math.ceil(AUTH_CONFIG.RATE_LIMIT_WINDOW / 1000).toString(),
+            'Retry-After': retryAfter.toString(),
             'Set-Cookie': `session=; HttpOnly; Path=/; ${process.env.NODE_ENV === 'production' ? 'Secure; ' : ''}SameSite=Strict; Max-Age=0`
           }
         }
       );
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/auth.ts` around lines 967 - 980, The current 429 handler uses
AUTH_CONFIG.RATE_LIMIT_WINDOW instead of the limiter's actual remaining
cooldown; update the return to compute Retry-After from the limiter result
returned by checkRateLimit (e.g., use rate.reset or rate.resetAt timestamp or a
rate.remainingMs value if provided) by calculating Math.ceil(Math.max(0,
(rate.reset - Date.now()) / 1000)) (or equivalent using resetAt/remainingMs),
fall back to Math.ceil(AUTH_CONFIG.RATE_LIMIT_WINDOW / 1000) if the limiter
result lacks that field, and use that computed value for the 'Retry-After'
header while preserving baseHeaders and the session cookie logic.
src/db/nip46.ts-431-433 (1)

431-433: ⚠️ Potential issue | 🟡 Minor

Avoid any[] for SQL params in strict TypeScript.

Line 432 uses an overly permissive any[] type where an explicit union would be more accurate and type-safe.

🧩 Suggested fix
-  const params: any[] = [userId]
+  const params: Array<string | number | bigint> = [userId]

Per coding guidelines (**/*.{ts,tsx}), TypeScript strict mode requires explicit types and avoiding any.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/db/nip46.ts` around lines 431 - 433, Replace the permissive params: any[]
with a precise union type for SQL parameters to satisfy strict TypeScript:
change params to something like params: (string | number | boolean | null |
Uint8Array)[] and update any downstream uses to match, keeping the initial
assignment params = [userId] (where userId should be typed to one of those union
members) and leave clauses: string[] unchanged; ensure functions that consume
params (e.g., the SQL execution call) accept the new union type.
src/server.ts-399-403 (1)

399-403: ⚠️ Potential issue | 🟡 Minor

Remove any usage in the prune timer block.

Lines 400 and 402 use any which violates the TypeScript strict mode requirement for explicit types.

♻️ Suggested typing fix
       runPrune()
       const interval = setInterval(runPrune, 6 * 60 * 60 * 1000)
-      ;(interval as any).unref?.()
+      const timer = interval as ReturnType<typeof setInterval> & { unref?: () => void }
+      timer.unref?.()
     }
-  } catch (e: any) {
-    console.error('⚠️  Failed to enable UI event log persistence:', e?.message || e)
+  } catch (e: unknown) {
+    const message = e instanceof Error ? e.message : String(e)
+    console.error('⚠️  Failed to enable UI event log persistence:', message)
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server.ts` around lines 399 - 403, Replace the use of `any` when storing
the interval and calling unref by giving `interval` a concrete timer type:
declare `interval` as ReturnType<typeof setInterval> (or NodeJS.Timeout) when
assigning setInterval in the prune timer block, then guard the unref call with a
runtime check and cast to NodeJS.Timeout (e.g., if ('unref' in interval)
(interval as NodeJS.Timeout).unref()). Update references to `interval` and keep
the existing `runPrune` usage unchanged.
docs/openapi/openapi.json-5-5 (1)

5-5: ⚠️ Potential issue | 🟡 Minor

Health-check cadence in info.description is stale.

The description says checks run every 30 seconds, but runtime connectivity checks are every 60 seconds. This should be aligned to avoid operator confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/openapi/openapi.json` at line 5, Update the OpenAPI info.description to
reflect the correct health-check cadence: locate the "info.description" string
in openapi.json and change the "Health Checks: Automated health checks every 30
seconds" line to state "every 60 seconds" so the documentation matches the
runtime connectivity checks.
src/node/manager.ts-744-745 (1)

744-745: ⚠️ Potential issue | 🟡 Minor

Clear cached probe result when canceling/superseding probes.

cancelBackgroundProbe() invalidates generations but keeps lastBackgroundProbeResult, so callers can read stale probe data after cleanup or node recreation.

🔧 Suggested fix
 async function runBackgroundRelayProbe(
   relays: string[],
   kind: number = 20004,
   addServerLog?: ReturnType<typeof createAddServerLog>
 ): Promise<void> {
   if (relays.length === 0) {
     return;
   }

   const myGeneration = ++backgroundProbeGeneration;
+  lastBackgroundProbeResult = null;
 
   try {
@@
 export function cancelBackgroundProbe(): void {
   backgroundProbeGeneration++;
+  lastBackgroundProbeResult = null;
 }

Also applies to: 806-808, 2450-2453

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/node/manager.ts` around lines 744 - 745, When invalidating/superseding
background probes you must also clear the cached probe result to avoid exposing
stale data: in the places where you bump backgroundProbeGeneration and capture
myGeneration (the line with "const myGeneration = ++backgroundProbeGeneration"
and other similar spots) and inside cancelBackgroundProbe(), set
lastBackgroundProbeResult = undefined (or null) immediately after
incrementing/invalidating the generation so any in-flight or cancelled probes
cannot be read from the stale cache; apply the same change to the other
generation-invalidation sites that touch backgroundProbeGeneration or
cancelBackgroundProbe().
docs/openapi/openapi.yaml-2769-2772 (1)

2769-2772: ⚠️ Potential issue | 🟡 Minor

Add explicit hex constraints for cryptographic key fields.

These fields are documented as fixed-size hex keys but currently accept arbitrary strings.

🧩 Suggested constraints
     Nip46SessionInput:
       type: object
       description: Client-supplied fields when creating or updating a NIP‑46 session
       properties:
         pubkey:
           type: string
+          pattern: '^[0-9a-fA-F]{64}$'
           description: Client public key (hex encoded)

@@
     Nip46TransportInput:
       type: object
       properties:
         transport_sk:
           type: string
+          pattern: '^[0-9a-fA-F]{64}$'
           description: 32-byte transport secret key encoded as hex

Also applies to: 2905-2908

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/openapi/openapi.yaml` around lines 2769 - 2772, The OpenAPI schema for
cryptographic key fields (e.g., the pubkey property) currently allows arbitrary
strings—update the pubkey schema to enforce hex encoding and exact length by
adding a pattern such as "^[0-9a-fA-F]{<HEX_LEN>}$" and corresponding
minLength/maxLength set to <HEX_LEN> (replace <HEX_LEN> with the documented hex
length), and similarly add the same pattern/length constraints to the other key
fields noted (the block around lines 2905-2908); ensure the pattern, minLength
and maxLength are present on each key property so the spec only accepts
fixed-size hex keys.
docs/openapi/openapi.yaml-1526-1531 (1)

1526-1531: ⚠️ Potential issue | 🟡 Minor

Require both fields when nextCursor is present.

nextCursor currently permits {} because the object branch has no required keys. This weakens paging behavior for generated clients.

📌 Suggested schema fix
                   nextCursor:
                     oneOf:
                       - type: object
+                        required: [createdAt, id]
                         properties:
                           createdAt: { type: string }
                           id: { type: string }
                       - type: 'null'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/openapi/openapi.yaml` around lines 1526 - 1531, The oneOf branch for
nextCursor allows an empty object because the object schema for the cursor has
no required properties; update the object branch (the one under oneOf) to
require both createdAt and id by adding a required array referencing "createdAt"
and "id" so that when nextCursor is present it must include both fields (the
symbols to edit are the oneOf entry for nextCursor and its object properties
createdAt and id).
frontend/types/nostr-connect.d.ts-2-6 (1)

2-6: ⚠️ Potential issue | 🟡 Minor

Replace loose unknown types with precise interfaces matching the actual @cmdcode/nostr-connect API.

The current types force downstream as any casts (see src/nip46/service.ts), violating strict mode. Per package documentation, InviteEncoder.decode() returns an Invite object; SignerAgent constructor accepts (signer: SignerDeviceAPI, options?: SignerAgentOptions); and SimpleSigner accepts (seckey?: string | Uint8Array). Define or import these interfaces to eliminate the need for unsafe type casts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/types/nostr-connect.d.ts` around lines 2 - 6, Replace the loose
unknowns in the exported types by importing or declaring the precise interfaces
from the `@cmdcode/nostr-connect` API: change InviteEncoder.decode to return
Invite (import or declare the Invite interface), type SignerAgent as new
(signer: SignerDeviceAPI, options?: SignerAgentOptions) => unknown (or better,
the concrete instance type) and type SimpleSigner as new (seckey?: string |
Uint8Array) => unknown; ensure you import or declare SignerDeviceAPI and
SignerAgentOptions (or the actual concrete return types) and update the exported
constants InviteEncoder, SignerAgent, and SimpleSigner to use those interfaces
so downstream code no longer needs as any casts.

Comment thread .github/workflows/ci.yml Outdated
Comment thread .github/workflows/release.yml Outdated
Comment thread .github/workflows/release.yml Outdated
Comment thread .github/workflows/umbrel-dev.yml Outdated
Comment thread docs/openapi/openapi.json
Comment thread src/config/crypto.ts
Comment thread src/routes/auth.ts
Comment thread src/routes/nip46.ts
Comment thread src/routes/peers.ts
Comment thread src/server.ts Outdated
@AustinKelsay

Copy link
Copy Markdown
Member Author

@CodeRabbit full review

@coderabbitai

coderabbitai Bot commented Mar 6, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 19

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (7)
static/index.html (1)

2-13: ⚠️ Potential issue | 🟠 Major

Do not modify generated assets under static/ directly.

This file is under static/**, so the class="dark" change and the formatting-only change should be made in the source template/build step, then regenerated rather than committed here.

As per coding guidelines, static/**: "Never edit generated assets under static/ by hand" and "Do not commit generated assets under static/ to source control as changes".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@static/index.html` around lines 2 - 13, The change to the generated asset
(the HTML <html lang="en" class="dark"> and formatting in the static index) must
not be edited directly; revert this file to the generated baseline and instead
update the source template/build configuration that produces the <html ...>
element (the template that controls the html tag and its class) so the class
change and formatting are applied there, then run the build/regeneration step to
produce a new static/index.html and commit only the source/template changes and
regenerated artifact per the guideline.
src/class/relay.ts (1)

233-252: ⚠️ Potential issue | 🟠 Major

Prevent duplicate EVENT emissions when filters overlap.

Starting at Line 235, replay iterates each filter independently and sends on every match. If one cached event matches multiple filters, the client can receive duplicate EVENT frames for the same subscription/event.

Suggested fix (dedupe replayed events per subscription)
   _onreq (
     sub_id  : string,
     filters : EventFilter[]
   ) : void {
@@
-    // For each filter:
+    const sentEventIds = new Set<string>()
+    // For each filter:
     for (const filter of filters) {
       // Set the limit count, if any.
       let limit_count = filter.limit
+      if (limit_count === 0) continue
       // For each event in the cache:
       for (const event of this.relay.cache) {
-        // If there is no limit, or we are above the limit:
-        if (limit_count === undefined || limit_count > 0) {
-          // If the event matches the current filter:
-          if (Nostr.match_filter(event, filter)) {
-            // Send the event to the client.
-            this.send(['EVENT', sub_id, event])
-            this.log.client(`event matched in cache: ${event.id}`)
-            this.log.client(`event matched subscription: ${sub_id}`)
-            // Decrement only when we actually sent a matching event.
-            if (limit_count !== undefined) {
-              limit_count -= 1
-              if (limit_count === 0) break
-            }
-          }
-        } 
+        if (limit_count !== undefined && limit_count <= 0) break
+        // If the event matches the current filter:
+        if (Nostr.match_filter(event, filter) && !sentEventIds.has(event.id)) {
+          sentEventIds.add(event.id)
+          // Send the event to the client.
+          this.send(['EVENT', sub_id, event])
+          this.log.client(`event matched in cache: ${event.id}`)
+          this.log.client(`event matched subscription: ${sub_id}`)
+          // Decrement only when we actually sent a matching event.
+          if (limit_count !== undefined) {
+            limit_count -= 1
+          }
+        }
       }
     }

Please also mirror this in live dispatch by short-circuiting after first match per subscription in _onevent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/class/relay.ts` around lines 233 - 252, Replay currently iterates filters
independently and can send the same cached event multiple times for sub_id;
modify the replay logic in the function that calls this.addSub and iterates
this.relay.cache to maintain a Set (e.g., sentEventIds) keyed by sub_id and
before sending check if event.id is already in the Set, only send and decrement
limit when you add it to the Set; mirror the same de-duplication in the live
path inside _onevent by short-circuiting per subscription (or tracking sent IDs
for that subscription) so the first Nostr.match_filter hit sends the EVENT and
prevents subsequent sends of the same event for the same subscription.
tests/routes/status-env.test.ts (1)

61-89: ⚠️ Potential issue | 🟡 Minor

This no longer proves DB-mode unavailability.

With AUTH_ENABLED turned on at Line 66 and no auth on the request, the 401 can come entirely from the auth guard. That means this test will still pass even if /api/env becomes reachable in database mode once authenticated. Please either authenticate the request and assert the DB-mode response, or rename the test to match the auth-only behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/routes/status-env.test.ts` around lines 61 - 89, The test currently
sets AUTH_ENABLED='true' which causes the 401 to possibly be from the auth guard
rather than DB-mode; update the test that calls handleEnvRoute so it
deterministically asserts DB-mode behavior: either disable auth (set
process.env.AUTH_ENABLED = 'false') so the request reaches handleEnvRoute's
DB-mode logic and assert the DB-specific 401/response, or keep
AUTH_ENABLED='true' but add valid authentication (headers or whatever your auth
flow expects in runRouteScript) and then assert the DB-mode error; reference
handleEnvRoute, runRouteScript and the test name ("env route unavailable in
database mode") when making the change.
llm/workflows/RELEASE_PROCESS.md (1)

182-201: ⚠️ Potential issue | 🟠 Major

Keep the release docs aligned with the repository’s actual default/release branch.

These changes now instruct maintainers to open, merge, tag from, and roll back main, but this PR is still targeting master. Following the documented commands as written will point release work at the wrong branch.

Also applies to: 328-350

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@llm/workflows/RELEASE_PROCESS.md` around lines 182 - 201, The docs
inconsistently reference the default release branch (mentions main in some
places but the repo uses master), which will misdirect release commands; update
the "Create a Pull Request" GH CLI example and all occurrences of "main" and
"master" (e.g., the gh pr create line with --base main, the branch name
"release/prepare-v<VERSION>", the PR merge instruction "When the PR is merged to
main", and the later block around lines ~328-350) to use a single canonical
placeholder like <DEFAULT_BRANCH> (or replace with the repository's actual
default branch name) and ensure the CLI/example commands and prose consistently
reference that placeholder so all commands target the correct branch.
src/db/database.ts (1)

1153-1169: ⚠️ Potential issue | 🔴 Critical

Add migration to create role column for legacy databases and add defensive runtime check.

The role column exists in the initial CREATE TABLE users schema (line 115) for fresh installs, but:

  1. No migration exists: The code comment at line 134 states "Legacy databases will be handled by dedicated migrations," but no such migration has been implemented. Legacy/upgraded databases will lack the role column entirely.

  2. No defensive check: Unlike peer_policies (which has ensurePeerPoliciesColumn() at line 137), there is no runtime fallback. Queries in deleteUserSafely will fail with no such column: role on upgraded installs.

  3. Risk: deleteUserSafely (lines 1153–1169) and the admin count query (line 1169) will fail when executed against upgraded databases, blocking user deletion operations.

Add a migration (20250922_XXXX_add_role_column.sql) that creates and backfills the role column for legacy installs, or add a defensive check analogous to ensurePeerPoliciesColumn() before accessing role in queries.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/db/database.ts` around lines 1153 - 1169, The deleteUserSafely flow fails
on upgraded/legacy DBs because the users.role column may be missing; add a
migration (e.g., 20250922_XXXX_add_role_column.sql) that ALTERs the users table
to add a role column and backfills values, and/or add a defensive runtime check
similar to ensurePeerPoliciesColumn() and call it before deleteUserSafely runs
(so queries referencing role won’t throw "no such column: role"); update
deleteUserSafely to rely on the ensured column or handle absence by treating
missing role as non-admin in the admin-count logic (refer to function names:
deleteUserSafely and ensurePeerPoliciesColumn and the users.role column).
src/routes/env.ts (1)

728-754: ⚠️ Potential issue | 🟠 Major

Deleting RELAYS never reapplies the running node configuration.

This path now removes process.env.RELAYS, but it only tears down/recreates the node when credentials are deleted. A POST /api/env/delete for ["RELAYS"] persists the new config while the live node keeps using the old relay set until some later restart.

Suggested fix
-          const deletingCredentials = validKeys.some(key => ['GROUP_CRED', 'SHARE_CRED'].includes(key));
+          const deletingCredentials = validKeys.some(key => ['GROUP_CRED', 'SHARE_CRED'].includes(key));
+          const deletingRelays = validKeys.includes('RELAYS');
@@
-            if (deletingCredentials) {
+            if (deletingCredentials) {
               try {
                 // Use synchronized cleanup to prevent race conditions
                 await cleanupNodeSynchronized(context);
               } catch (error) {
                 // Error already logged by executeUnderNodeLock
                 // Continue anyway - the env vars were deleted
               }
+            } else if (deletingRelays) {
+              try {
+                await executeUnderNodeLock(async () => {
+                  await createAndConnectServerNode(env, context);
+                }, context);
+              } catch (error) {
+                return Response.json({ success: false, message: 'Failed to reconfigure relays' }, { status: 500, headers });
+              }
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/env.ts` around lines 728 - 754, The live node isn't reconfigured
when RELAYS is deleted because deletingCredentials only checks
GROUP_CRED/SHARE_CRED; update the logic so removing RELAYS also triggers the
node cleanup: either include 'RELAYS' in the deletingCredentials determination
or add a relaysChanged boolean and include it in the same conditional that calls
await cleanupNodeSynchronized(context); also ensure process.env.RELAYS is
deleted (the deletion loop in the validKeys handling or the try block that
clears process.env values) happens before calling cleanupNodeSynchronized so the
synchronized cleanup sees the updated environment (refer to deletingCredentials,
validKeys loop, process.env.RELAYS, writeEnvFile, and cleanupNodeSynchronized).
src/routes/nip46.ts (1)

93-121: ⚠️ Potential issue | 🟡 Minor

Reject malformed policy patches instead of dropping them.

If policy.methods or policy.kinds contains bad keys/non-boolean values, this returns null and POST /requests can still succeed without applying the requested policy update. Reuse the stricter parseBooleanPolicyMap() path here so malformed patches fail closed with 400.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/nip46.ts` around lines 93 - 121, Update parsePolicyPatch to reuse
the stricter parseBooleanPolicyMap for both the methods and kinds branches: when
(value as any).methods or (value as any).kinds is present, call
parseBooleanPolicyMap(...) to parse/validate it; if parseBooleanPolicyMap
indicates the input is malformed (i.e. returns an error/null/invalid marker),
have parsePolicyPatch fail closed by returning null so the request becomes a
400, and only set patch.methods/patch.kinds when parseBooleanPolicyMap returns a
valid non-empty map. Keep references to parsePolicyPatch, parseBooleanPolicyMap
and the properties methods/kinds so the change is localized.
♻️ Duplicate comments (19)
frontend/components/Configure.tsx (1)

625-633: ⚠️ Potential issue | 🟠 Major

Reject malformed headless relay input instead of persisting the default relay set.

This still silently falls back to the default relay list when advancedSettings.RELAYS is present but invalid, and then persists that fallback in the headless save path. That can overwrite an intended relay configuration without any user confirmation.

💡 Safer relay resolution
       const resolveRelaysToSave = (): string[] => {
+        const sanitizedExisting = Array.isArray(existingRelays)
+          ? existingRelays.map((relay) => relay.trim()).filter((relay) => relay.length > 0)
+          : [];
+
         if (!isHeadlessMode) {
-          if (Array.isArray(existingRelays) && existingRelays.length > 0) {
-            return existingRelays.map((relay) => relay.trim()).filter((relay) => relay.length > 0);
-          }
+          if (sanitizedExisting.length > 0) return sanitizedExisting;
           return ["wss://relay.primal.net"];
         }
 
         if (typeof advancedSettings.RELAYS === 'string' && advancedSettings.RELAYS.trim().length > 0) {
           const parsedRelays = parseRelayList(advancedSettings.RELAYS);
-          if (parsedRelays) return parsedRelays;
+          if (!parsedRelays) {
+            throw new Error('RELAYS must be a non-empty JSON array of relay URLs.');
+          }
+          return parsedRelays;
         }
-        if (Array.isArray(existingRelays) && existingRelays.length > 0) {
-          return existingRelays.map((relay) => relay.trim()).filter((relay) => relay.length > 0);
-        }
+        if (sanitizedExisting.length > 0) return sanitizedExisting;
         return ["wss://relay.primal.net"];
       };

Also applies to: 638-650

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/Configure.tsx` around lines 625 - 633, The current
resolver silently falls back to defaults when advancedSettings.RELAYS is present
but malformed; change the logic so that if typeof advancedSettings.RELAYS ===
'string' and parseRelayList(advancedSettings.RELAYS) returns an invalid result,
the function returns a clear failure value (e.g., null/undefined) or throws so
the headless save path can detect and reject the input instead of persisting the
default list; keep using parseRelayList for valid strings and only fall back to
existingRelays/default when advancedSettings.RELAYS is completely absent, and
apply the same fix to the similar block handling lines 638-650.
.github/workflows/ci.yml (1)

5-7: ⚠️ Potential issue | 🔴 Critical

Restore CI coverage for master.

This PR targets master, but Lines 5-7 only trigger on main and dev. Pushes and pull requests against master will skip the workflow entirely.

Suggested fix
 on:
   push:
-    branches: [ main, dev ]
+    branches: [ main, master, dev ]
   pull_request:
-    branches: [ main, dev ]
+    branches: [ main, master, dev ]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/ci.yml around lines 5 - 7, The workflow triggers currently
list only the branch arrays "branches: [ main, dev ]" for both push and
"pull_request" so pushes and PRs against "master" are skipped; update both
"branches" arrays under the push trigger and the "pull_request" trigger to
include "master" (e.g., change to "branches: [ main, dev, master ]") so the CI
runs for master as well.
src/config/crypto.ts (1)

39-40: ⚠️ Potential issue | 🟠 Major

Exclude whitespace from the shared password regex.

Lines 39-40 still allow spaces/tabs to satisfy the “special character” requirement. That diverges from the onboarding/frontend rule, so a password can pass here and fail later in the auth flow.

Suggested fix
-  PASSWORD_REGEX: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).+$/,
+  PASSWORD_REGEX: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9\s]).+$/,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/crypto.ts` around lines 39 - 40, The PASSWORD_REGEX currently
allows whitespace to count as the "special character" and permits spaces
overall; update the PASSWORD_REGEX constant so the special-character lookahead
excludes whitespace and the whole pattern forbids any whitespace. Replace
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).+$/ with a pattern like
^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9\s])\S+$ in the PASSWORD_REGEX
definition in src/config/crypto.ts so passwords cannot contain whitespace and
whitespace cannot satisfy the special-character requirement.
llm/implementation/umbrel-implementation.md (1)

72-76: ⚠️ Potential issue | 🟡 Minor

Add the source-repo version bump to this checklist.

The checklist updates store-side metadata, but it still never reminds the releaser to bump package.json (or this repo’s canonical version source) before publishing. That leaves the app version and the Umbrel store version free to drift again.

Suggested fix
 ## Update Checklist for Future Releases
-1. Build and push the new Umbrel image (`:umbrel-<version>` and `:umbrel-latest`).
-2. Bump the digest in `igloo-server/docker-compose.yml` (keep the `:umbrel-dev` tag; only the `@sha256:...` digest changes).
-3. Revise `igloo-server/umbrel-app.yml` version and release notes.
-4. Refresh gallery assets if the UI has changed.
+1. Bump this repo's application version (`package.json` or equivalent) and commit it.
+2. Build and push the new Umbrel image (`:umbrel-<version>` and `:umbrel-latest`).
+3. Bump the digest in `igloo-server/docker-compose.yml` (keep the `:umbrel-dev` tag; only the `@sha256:...` digest changes).
+4. Revise `igloo-server/umbrel-app.yml` version and release notes.
+5. Refresh gallery assets if the UI has changed.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@llm/implementation/umbrel-implementation.md` around lines 72 - 76, Update the
"Update Checklist for Future Releases" in
llm/implementation/umbrel-implementation.md to include an explicit step to bump
the repository's canonical version (e.g., update package.json version or
whatever file holds the canonical version) before publishing; reference the
package.json (or the repo's canonical version file) and instruct maintainers to
update that version and commit the change alongside the Umbrel image/digest and
release notes to prevent version drift.
frontend/components/ui/log-entry.tsx (1)

112-118: ⚠️ Potential issue | 🟡 Minor

Treat a 200 blob response without data as a fetch failure.

The promise still silently no-ops when the payload object lacks data. The spinner clears, fetchError stays null, and the user is left with a preview/blank panel with no retry hint.

Suggested fix
       .then(payload => {
         if (cancelled) return;
         if (payload && typeof payload === 'object' && 'data' in payload) {
           setResolvedData((payload as any).data);
           setHasFetchedFull(true);
+        } else {
+          setFetchError('failed_to_load_payload');
         }
       })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/ui/log-entry.tsx` around lines 112 - 118, The
then-handler currently silently no-ops when a 200 response object lacks a data
field; update the handler so that if payload is an object but does not contain
'data' you treat it as a fetch failure by calling the existing error state
setter (e.g. setFetchError(new Error('Missing data in response'))), ensure any
loading state is cleared (e.g. setHasFetchedFull(false) or the same logic you
use for failures), and avoid leaving stale preview content (clear
setResolvedData or setResolvedData(null) as appropriate). Locate this logic
around the promise .then(...) where setResolvedData and setHasFetchedFull are
called and add the error branch for payload objects without 'data'.
.github/workflows/release.yml (2)

14-14: ⚠️ Potential issue | 🟠 Major

This gate is still hard-coded to main.

The repository state in this PR is still master-based, so a manual dispatch from the active release branch never enters the release job. Please switch this to github.event.repository.default_branch (or allow both branch names) before shipping.

Suggested fix
-    if: github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch'
+    if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.event_name == 'workflow_dispatch'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/release.yml at line 14, The CI gate currently hard-codes
the branch check in the workflow condition (the expression using "github.ref ==
'refs/heads/main'") which prevents runs on repositories where the default branch
is "master"; update the conditional in the release job to reference
github.event.repository.default_branch (e.g., compare github.ref to
format('refs/heads/{0}', github.event.repository.default_branch)) or modify the
expression to accept both 'refs/heads/main' and 'refs/heads/master' so manual
dispatches on the repository's active branch trigger the release job.

119-123: ⚠️ Potential issue | 🟠 Major

The changelog insertion condition still misses dated headings.

The script creates headings as ## [x.y.z] - YYYY-MM-DD, but this block only inserts when the line equals ## [x.y.z]. On a normal run the generated change list never gets inserted.

Suggested fix
-          $0 == target && !inserted {
+          index($0, target) == 1 && !inserted {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/release.yml around lines 119 - 123, The awk condition
checks exact equality ($0 == target) so it misses headings with dates like "##
[x.y.z] - YYYY-MM-DD"; update the insertion condition in the awk script that
uses target and inserted (the block reading insert_file/temp_changelog) to match
a heading prefix instead of exact equality — e.g., replace "$0 == target &&
!inserted" with a prefix or regex test such as "index($0, target) == 1 &&
!inserted" or "$0 ~ (\"^\" target) && !inserted" so lines like "## [${VERSION}]
- YYYY-MM-DD" will be detected and the changelog inserted.
src/routes/peers.ts (1)

859-933: ⚠️ Potential issue | 🟠 Major

Return 400 for invalid single-peer ping targets.

normalizePubkey(target) still sits inside the generic try, so malformed client input falls through to the catch path and comes back as HTTP 200 with success: false. That should stay a bad-request response, like the per-policy route already does.

Suggested fix
 async function handlePingSinglePeer(target: string, context: RouteContext, headers: Record<string, string>): Promise<Response> {
+  const normalizedPubkey = safeNormalizePubkey(target);
+  if (!normalizedPubkey) {
+    return Response.json(
+      { error: 'Peer pubkey must be a 64-character hex string', pubkey: target, success: false },
+      { status: 400, headers }
+    );
+  }
+
   try {
     // Ping specific peer
-    const normalizedPubkey = normalizePubkey(target);
     const startTime = Date.now();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/peers.ts` around lines 859 - 933, The function
handlePingSinglePeer currently calls normalizePubkey(target) inside the try
block so malformed input is handled as a runtime error and returns HTTP 200;
move the pubkey validation out of the try: call normalizePubkey (or
safeNormalizePubkey to validate) before the try and if the target is invalid
return an immediate 400 Response.json with the same headers shape as other
responses; then use the validated normalizedPubkey variable inside the try (and
when updating context.peerStatuses and broadcasting) so input validation
produces a 400 Bad Request instead of falling through to the catch path.
frontend/components/ui/peer-list.tsx (1)

377-417: ⚠️ Potential issue | 🟡 Minor

Type the peer-update event payload explicitly.

CustomEvent without a detail generic still leaves event.detail untyped here, so this normalized update path loses type checking right where it now parses latency and dates.

Suggested fix
+interface PeerUpdateDetail {
+  pubkey: string;
+  status: {
+    online?: unknown;
+    latency?: unknown;
+    lastSeen?: unknown;
+    lastPingAttempt?: unknown;
+  };
+}
+
-const handlePeerUpdate = useCallback((event: CustomEvent) => {
+const handlePeerUpdate = useCallback((event: CustomEvent<PeerUpdateDetail>) => {
   const { pubkey, status } = event.detail;
As per coding guidelines "TypeScript strict mode; explicit types, avoid `any`".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/ui/peer-list.tsx` around lines 377 - 417, The handler
uses an untyped CustomEvent so event.detail is implicitly any; explicitly type
the event payload (e.g., CustomEvent<PeerUpdatePayload>) and/or reuse an
existing type (e.g., PeerStatusUpdate or PeerStatus) so the detail shape
(pubkey, status with latency, lastSeen, lastPingAttempt, online) is enforced;
update the handlePeerUpdate signature to accept CustomEvent<YourPayloadType> and
add/adjust the payload interface definition to match the parsed fields used in
handlePeerUpdate (pubkey: string, status: { latency?: number | string | null;
lastSeen?: string | null; lastPingAttempt?: string | null; online?: boolean })
so Number(status.latency) and parseDate calls are type-checked.
llm/context/ENVIRONMENT_VARIABLES.md (1)

143-155: ⚠️ Potential issue | 🟡 Minor

UI_EVENT_LOG_INCLUDE_PINGS is still missing from the env reference.

This document now covers most runtime knobs, but operators still can't discover the toggle that controls whether ping events are recorded in the UI event log. If this is meant to be the definitive reference, that variable should be listed with its default and source.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@llm/context/ENVIRONMENT_VARIABLES.md` around lines 143 - 155, Add a missing
table row for the environment variable UI_EVENT_LOG_INCLUDE_PINGS to
ENVIRONMENT_VARIABLES.md: create an entry with Variable =
`UI_EVENT_LOG_INCLUDE_PINGS`, Purpose = "Include ping events in UI event log",
Both Modes Usage = "Identical behavior", Default = the actual default value
pulled from the source code, Notes = "Controls whether ping events are recorded
in the UI event log", and Source = the exact source file where the variable is
defined/used (look up the definition/usage in the repo and reference that file).
Ensure the row follows the same column order and style as the other entries in
the Relay Probing & Startup Performance table.
frontend/components/Signer.tsx (1)

540-545: ⚠️ Potential issue | 🟠 Major

Cap the merged history pages too.

The realtime append path enforces MAX_EVENT_LOG_IN_MEMORY, but the merges at Line 544 and Line 582 return the full merged array. Repeated “Load older” actions can therefore grow memory without bound and undo the cap added at Line 49. Apply the same trim after both merges.

Also applies to: 579-583

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/Signer.tsx` around lines 540 - 545, The merged results
returned from setLogs (inside the realtime append and the "Load older" merge
that combines chronological and prev) can exceed MAX_EVENT_LOG_IN_MEMORY; after
you build merged (e.g., const merged = [...chronological.filter(...), ...prev])
trim it to the cap before returning (slice or similar) so the final array
respects MAX_EVENT_LOG_IN_MEMORY. Do this in both places where merged is
returned in the setLogs callbacks (referencing setLogs, chronological, prev,
merged and MAX_EVENT_LOG_IN_MEMORY).
src/server.ts (1)

658-662: ⚠️ Potential issue | 🟠 Major

Default query-string WebSocket auth to off.

Lines 660-662 still enable ?apiKey= / ?sessionId= auth when ALLOW_QUERY_CREDENTIALS is unset. Query credentials leak too easily via browser history, reverse-proxy logs, and metrics, so this should require an explicit opt-in instead of being the compatibility default.

🔐 Suggested hardening
 const parsedAllowQueryCredentials = (process.env.ALLOW_QUERY_CREDENTIALS ?? '').trim().toLowerCase();
-// Backward-compatible default: allow legacy ?apiKey= / ?sessionId= WS auth unless explicitly disabled.
-const WS_ALLOW_QUERY_CREDENTIALS = parsedAllowQueryCredentials === ''
-  ? true
-  : !['0', 'false', 'no', 'off'].includes(parsedAllowQueryCredentials);
+// Secure default: only enable query-string credentials when explicitly opted in.
+const WS_ALLOW_QUERY_CREDENTIALS = ['1', 'true', 'yes', 'on'].includes(parsedAllowQueryCredentials);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server.ts` around lines 658 - 662, The current logic treats an unset
ALLOW_QUERY_CREDENTIALS as opt-in by setting WS_ALLOW_QUERY_CREDENTIALS true
when parsedAllowQueryCredentials === ''; change it so the default is opt-out:
make WS_ALLOW_QUERY_CREDENTIALS false when parsedAllowQueryCredentials is empty
and only true when the env is explicitly set to an affirmative value; update the
expression that sets WS_ALLOW_QUERY_CREDENTIALS (which references
parsedAllowQueryCredentials and process.env.ALLOW_QUERY_CREDENTIALS) to return
true only for explicit positives (e.g., '1','true','yes','on') and false
otherwise.
docs/AUTH_MATRIX.md (1)

17-17: ⚠️ Potential issue | 🟡 Minor

Clarify /api/update behavior in headless/managed modes.

This row still reads as contradictory: the matrix says /api/update exists in headless mode, but the note says update checks are disabled when HEADLESS=true or SKIP_ADMIN_SECRET_VALIDATION=true. Please spell out whether the route remains mounted and returns a "disabled" response, or whether it is effectively unavailable in those modes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/AUTH_MATRIX.md` at line 17, Clarify the `/api/update` entry in
AUTH_MATRIX.md to remove the contradiction: explicitly state whether the
`/api/update` route remains mounted or is unmounted in headless/managed modes
and what response it returns; update the table row and the explanatory note to
say either "route remains mounted and returns { status: 'disabled' } (or HTTP
404/410 if unmounted)"—whichever is correct for the implementation—so consumers
know if the endpoint exists and what response to expect, and reference the
`/api/update` route name and the existing note text when making the change.
tests/routes/event-log.db-mode.spec.ts (2)

26-26: ⚠️ Potential issue | 🟡 Minor

Replace {} as any with a typed route-context stub.

any removes the strict-mode guardrail here, so these tests will keep passing even if handleEventLogRoute starts depending on additional context fields. Use the route context type, or a minimal typed stub that matches the handler signature.

#!/bin/bash
rg -n -C3 'handleEventLogRoute\s*\(' src/routes/event-log.ts
rg -n -C3 'type .*Context|interface .*Context' src

As per coding guidelines: **/*.{ts,tsx}: Enable TypeScript strict mode, declare explicit types, and avoid any.

Also applies to: 90-90, 141-141

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/routes/event-log.db-mode.spec.ts` at line 26, Replace the untyped stub
"const context = {} as any;" with a properly typed route-context stub that
matches the handler signature used by handleEventLogRoute: locate the route
context type or interface (search for types/interfaces named RouteContext,
RequestContext, or similar in src, or inspect the handleEventLogRoute parameter
type) and construct a minimal typed object providing only the fields the handler
reads (e.g., user, params, logger, db) so the test fails if new context fields
are required; update the tests at the referenced lines (26, 90, 141) to use that
typed stub instead of any.

1-2: ⚠️ Potential issue | 🟡 Minor

Co-locate this spec with src/routes/event-log.ts.

Route specs are supposed to live beside the route they cover; keeping this under tests/routes is still out of step with the repo convention.
As per coding guidelines: **/*.{test,spec}.ts: Co-locate backend tests with code and name them feature.test.ts or feature.spec.ts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/routes/event-log.db-mode.spec.ts` around lines 1 - 2, Move the spec
file tests/routes/event-log.db-mode.spec.ts to live beside the implementation
src/routes/event-log.ts (e.g., src/routes/event-log.spec.ts or
event-log.db-mode.spec.ts) so the test is co-located with the route; after
moving, update any imports inside the test (such as runRouteScript and
PROJECT_ROOT) to correct relative paths from the new location and confirm the
test file name follows the repo convention (feature.test.ts or feature.spec.ts).
docs/RELEASE.md (1)

40-43: ⚠️ Potential issue | 🟠 Major

Keep the release guide on master until the repo actually switches.

PR #35 is still targeting master as of February 4, 2026, so these instructions would send release PRs, hotfixes, and the dev sync step to the wrong branch.

Also applies to: 53-53, 69-73

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/RELEASE.md` around lines 40 - 43, The release guide currently instructs
creating release PRs against `main` but the repo still uses `master` (PR `#35`
targets `master`), so update all occurrences that instruct or show creating PRs,
hotfixes, and the dev-sync step to target `master` until the repository actually
switches default branches; specifically change the example "Create PR:
`release/prepare-v1.1.1` → `main`" and the related sections referenced (lines
around the shown diff and the other occurrences at 53 and 69-73) to use
`master`, and add a short note that these will be updated once the repo
officially switches to `main`.
docs/openapi/openapi.json (2)

2463-2527: ⚠️ Potential issue | 🟠 Major

openapi.json still exposes transport_sk in NIP-46 transport responses.

Line 2475 and Line 2512 still model the response with secret-key naming, and Line 4263 keeps Nip46Transport as transport_sk. This leaves the JSON spec contract stale and can break generated clients.

🔧 Align JSON transport schemas with the current public contract
@@ "/api/nip46/transport" -> "get" -> "responses" -> "200" -> "content" -> "application/json" -> "schema"
- "$ref": "#/components/schemas/Nip46Transport"
+ "$ref": "#/components/schemas/Nip46TransportPublic"

@@ "/api/nip46/transport" -> "put" -> "requestBody" -> "content" -> "application/json" -> "schema"
- "$ref": "#/components/schemas/Nip46Transport"
+ "$ref": "#/components/schemas/Nip46TransportInput"

@@ "/api/nip46/transport" -> "put" -> "responses" -> "200" -> "content" -> "application/json" -> "schema" -> "properties"
- "transport_sk": {
+ "transport_pubkey": {
    "type": "string"
  }

@@ components.schemas
+ "Nip46TransportInput": {
+   "type": "object",
+   "properties": {
+     "transport_sk": {
+       "type": "string",
+       "description": "32-byte transport secret key encoded as hex"
+     }
+   },
+   "required": ["transport_sk"]
+ },
+ "Nip46TransportPublic": {
+   "type": "object",
+   "properties": {
+     "transport_pubkey": {
+       "type": "string",
+       "description": "Transport public key derived from the stored NIP-46 secret"
+     }
+   },
+   "required": ["transport_pubkey"]
+ },
- "Nip46Transport": {
-   "type": "object",
-   "properties": {
-     "transport_sk": {
-       "type": "string",
-       "description": "32-byte transport secret key encoded as hex"
-     }
-   },
-   "required": ["transport_sk"]
- }

Also applies to: 4260-4270

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/openapi/openapi.json` around lines 2463 - 2527, The OpenAPI spec still
exposes the secret-key field name transport_sk in the NIP‑46 endpoints and
schema; update all occurrences so the public contract matches the current API:
change response/request schemas for operationId getNip46Transport and
updateNip46Transport and the components schema Nip46Transport to remove or
rename transport_sk to the public field (e.g., transport_key) or otherwise match
the non-secret public property the API returns, and ensure the 200 response
object under updateNip46Transport likewise uses the corrected property name (and
update any example/refs at lines referenced by Nip46Transport to keep the spec
consistent).

164-235: ⚠️ Potential issue | 🟡 Minor

/api/events still documents conditional auth but is modeled as always authenticated.

Line 168 says auth is conditional on AUTH_ENABLED, but this operation has no local security override, so it inherits global security and is documented as mandatory-auth for all environments.

📘 Suggested OpenAPI adjustment
 "/api/events": {
   "get": {
+    "security": [],
+    "x-public": true,
     "operationId": "eventsWebSocket",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/openapi/openapi.json` around lines 164 - 235, The OpenAPI entry for the
GET operation with operationId "eventsWebSocket" currently inherits global
security (making auth mandatory); add a local security override to the operation
(e.g., add "security": [] directly under the GET object for "/api/events" /
operationId "eventsWebSocket") so it does not inherit global required auth, and
keep the existing description text explaining AUTH_ENABLED behavior.
docs/openapi/openapi.yaml (1)

121-125: ⚠️ Potential issue | 🟠 Major

Remove token-bearing subprotocol and query-param auth guidance from /api/events.

Line 121 and Line 125 still document sending credentials via Sec-WebSocket-Protocol or URL query params. That conflicts with the security guidance at Line 2217/2233 and increases secret leakage risk.

🔐 Suggested hardening
-        - For non-browser clients, credentials may be provided via `Sec-WebSocket-Protocol` hints (first offered protocol is echoed back):
-          - `apikey.<TOKEN>` or `api-key.<TOKEN>` maps to `X-API-Key: <TOKEN>`
-          - `bearer.<TOKEN>` maps to `Authorization: Bearer <TOKEN>`
-          - `session.<ID>` maps to `X-Session-ID: <ID>`
-        - Legacy query params `apiKey` and `sessionId` are supported for compatibility but are discouraged.
+        - For non-browser clients, use standard authentication mechanisms (`Authorization`, `X-API-Key`, `X-Session-ID`, or secure session cookies).
+        - Do not place credentials in URL query parameters or token-bearing WebSocket subprotocol values.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/openapi/openapi.yaml` around lines 121 - 125, Update the /api/events
OpenAPI documentation to remove guidance that describes sending credentials via
Sec-WebSocket-Protocol subprotocols (the "apikey.<TOKEN>", "api-key.<TOKEN>",
"bearer.<TOKEN>", "session.<ID>" examples) and remove the note about legacy
query params ("apiKey" and "sessionId"); edit the /api/events description block
in openapi.yaml so it no longer instructs clients to provide secrets via
Sec-WebSocket-Protocol or URL query parameters and instead leaves auth to the
centralized security definitions referenced elsewhere in the spec.
🧹 Nitpick comments (13)
frontend/components/nip46/QRScanner.tsx (1)

31-89: Consider guarding against unmount during async initialization.

If the component unmounts while initializeScanner() is executing (between scanner creation at line 45 and ref assignment at line 65), the cleanup function won't destroy the scanner since scannerRef.current is still null at that point. This could leave the scanner running and the camera active.

♻️ Proposed fix using a mounted flag
   useEffect(() => {
     let scanner: QrScanner | null = null
+    let mounted = true
 
     const initializeScanner = async () => {
       if (!videoRef.current) {
         setError('Video element not found')
         return
       }
 
       try {
         if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
           throw new Error('Camera access is not supported in this browser')
         }
 
         scanner = new QrScanner(
           videoRef.current,
           (result: QrScanner.ScanResult) => {
             if (scanningRef.current) return
             if (result.data && result.data.startsWith('nostrconnect://')) {
               scanningRef.current = true
               onResultRef.current(result.data)
               scannerRef.current?.destroy()
               scannerRef.current = null
               timeoutRef.current = setTimeout(() => {
                 scanningRef.current = false
               }, 1000)
             }
           },
           { returnDetailedScanResult: true, highlightScanRegion: true, highlightCodeOutline: true }
         )
 
         await scanner.start()
+        if (!mounted) {
+          scanner.destroy()
+          return
+        }
         setError(null)
         setHasPermission(true)
         scannerRef.current = scanner
       } catch (err) {
         const e = err instanceof Error ? err : new Error('Failed to initialize scanner')
+        if (!mounted) return
         setError(e.message)
         setHasPermission(false)
         if (onErrorRef.current) {
           onErrorRef.current(e);
         } else {
           console.error('Failed to start QR scanner:', e);
         }
         scanner?.destroy()
         scannerRef.current = null
       }
     }
 
     initializeScanner()
     return () => {
+      mounted = false
       if (timeoutRef.current) {
         clearTimeout(timeoutRef.current)
       }
       scannerRef.current?.stop()
       scannerRef.current?.destroy()
       scannerRef.current = null
     }
   }, [])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/nip46/QRScanner.tsx` around lines 31 - 89, The
initializeScanner async flow can create a local scanner before
scannerRef.current is set, so if the component unmounts during initialization
the cleanup won't destroy it; fix by introducing a mounted/aborted flag inside
the useEffect (e.g., let mounted = true / const isMountedRef) and check it after
awaiting operations and before assigning scannerRef.current or calling setState;
also in the cleanup set mounted = false and explicitly destroy/stop the local
scanner variable (the `scanner` declared in initializeScanner) if it exists to
ensure the camera is released; update references in initializeScanner,
scannerRef, timeoutRef, and the cleanup closure accordingly.
src/routes/user.ts (1)

241-241: Consider adding explicit types for request body parsing.

The coding guidelines specify to "avoid any". While runtime validation is performed, consider defining an interface or inline type for the expected body shape to improve type safety and IDE support.

interface CredentialsUpdateBody {
  group_cred?: string | null;
  share_cred?: string | null;
  relays?: string[] | null;
  group_name?: string | null;
}

As per coding guidelines, TypeScript strict mode requires explicit types and avoiding any.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/user.ts` at line 241, The variable "body" is currently typed as
any (let body: any;) which violates the guideline to avoid any; define an
explicit interface (e.g., CredentialsUpdateBody with group_cred, share_cred,
relays, group_name) and replace the any with that type wherever the request body
is parsed (use: let body: CredentialsUpdateBody or the appropriate handler
parameter type), update any downstream usages to respect the typed properties,
and if runtime parsing/validation is used keep that but narrow types by casting
only after validation (or use a typed validation result) so the code no longer
declares body as any.
scripts/patch-zod-compat.mjs (1)

45-48: Make the schema rewrite less brittle to upstream changes.

This still only patches one exact import spelling. A quote-style or whitespace change in @cmdcode/nostr-connect will silently leave the old zod import in place. A regex replacement plus an assertion that at least one file was rewritten would make this patch much safer across dependency updates.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/patch-zod-compat.mjs` around lines 45 - 48, The current replacement
logic uses current.includes("import { z } from 'zod';") and replaceAll which is
brittle; update the patch to use a regex that matches different quote styles and
whitespace (e.g. a pattern matching import\s*\{\s*z\s*\}\s*from\s*['"]zod['"];),
perform a replace with the target value (the existing target variable), write
the updated content via writeFileSync(fullPath, updated) only when a change
occurred, and add an assertion or counter (e.g. track filesModified) to ensure
at least one file was rewritten and throw or exit non-zero if none were changed
so the script fails loudly when the upstream import style differs; keep
references to current.includes, replaceAll, writeFileSync, fullPath and target
while replacing replaceAll usage with a regex-based replace and adding the
post-run assertion.
frontend/components/Recover.tsx (2)

418-441: Consider validating nsec format before storing.

The validation checks for a non-empty string but doesn't verify the nsec format (e.g., should start with nsec1). If the server returns malformed data, users would see it as a valid recovery.

💡 Optional: Add format validation
-        if (typeof result.nsec !== 'string' || result.nsec.length === 0) {
+        if (typeof result.nsec !== 'string' || result.nsec.length === 0 || !result.nsec.startsWith('nsec1')) {
           throw new Error('Recovery completed without an nsec payload');
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/Recover.tsx` around lines 418 - 441, The code accepts any
non-empty string for result.nsec; validate the nsec format before calling
setRecoveredNsec and updating state in the success branch of the Recover
component: check result.nsec matches the expected prefix/format (e.g.,
startsWith('nsec1') or use a regex) and if it fails, throw an error or setResult
to a failure message instead of storing it; update the logic around
setRecoveredNsec and setResult where result.nsec is used so only properly
formatted nsec values are accepted and malformed responses are handled
gracefully.

458-467: Consider resetting copy status after a timeout.

The copyStatus remains as 'copied' or 'error' indefinitely after the clipboard operation. For better UX, reset to 'idle' after a few seconds.

♻️ Proposed fix: Auto-reset copy status
 const handleCopyRecoveredNsec = async () => {
   if (!recoveredNsec) return;
   try {
     await navigator.clipboard.writeText(recoveredNsec);
     setCopyStatus('copied');
+    setTimeout(() => setCopyStatus('idle'), 2000);
   } catch (error) {
     console.error('Failed to copy recovered NSEC:', error);
     setCopyStatus('error');
+    setTimeout(() => setCopyStatus('idle'), 3000);
   }
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/Recover.tsx` around lines 458 - 467, The
handleCopyRecoveredNsec function sets copyStatus to 'copied' or 'error' but
never resets it; update handleCopyRecoveredNsec to start a short timer (e.g.,
2–3 seconds) after setting copyStatus that calls setCopyStatus('idle') to revert
the UI, and ensure you clear any existing timer before starting a new one to
avoid leaks—use a ref or component-level variable to store the timeout id and
clearTimeout on subsequent calls and on unmount (cleanup in useEffect) so
repeated copies or component teardown don't leave dangling timers.
src/class/relay.test.ts (1)

85-104: Add an overlap-filter regression test for duplicate EVENT delivery.

This multi-filter test is solid, but it doesn’t cover the case where one event matches both filters. Please add an assertion that only one EVENT is emitted for the same event/subscription when filters overlap.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/class/relay.test.ts` around lines 85 - 104, Add an assertion that
overlapping filters don't cause duplicate EVENT delivery: after creating the
subscription via relay.handler()/handler.message (the existing test uses
NostrRelay, handler, ws and socket), construct an event that matches both
filters (e.g., same kind and authorHex), send it into the relay via
handler.message (JSON.stringify(['EVENT', event])) and then use
decodeSent(socket) to assert there is exactly one ['EVENT', event] sent for
'sub-multi' (or check that the count of messages equal to that
event/subscription pair is 1). Ensure you reference the existing symbols:
NostrRelay, handler.message, relay.subs, sub.filters, socket and decodeSent.
frontend/components/ui/collapsible.tsx (1)

69-69: Note: max-h-screen may affect animation smoothness for tall content.

The change from a fixed max-h-[500px] to max-h-screen provides flexibility but may cause jarring transitions for content significantly shorter than viewport height. If consistent animation timing is important, consider using a CSS variable or JavaScript-based height calculation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/ui/collapsible.tsx` at line 69, The change to use
"max-h-screen" for the animated container (the class toggled by isExpanded in
the Collapsible component) can cause jarring transitions for shorter content;
revert to a predictable max-height by either switching to a CSS variable (e.g.,
replace "max-h-screen" with a utility that uses a CSS var like
max-h-[var(--collapsible-max-h)] and set --collapsible-max-h in CSS or parent)
or compute and apply an inline maxHeight in the Collapsible component (measure
content in a ref in useEffect and set style.maxHeight when isExpanded) so
animations remain smooth and consistent; update the class toggle and/or add the
measurement logic around the element referenced by the isExpanded toggle to
implement this.
frontend/components/ui/log-entry.tsx (1)

12-16: Replace the new any payload types with unknown or a small union.

The lazy-load path adds more untyped surface (dataPreview and resolvedData), which weakens the guards below and works against strict-mode catches.

As per coding guidelines, "TypeScript strict mode; explicit types, avoid any".

Also applies to: 79-82

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/ui/log-entry.tsx` around lines 12 - 16, The property
types using `any` should be replaced with safer types: change `dataPreview?:
any` (and similar `any` uses around `resolvedData`) to `unknown` or a narrow
union of expected shapes (e.g., string | Record<string, unknown> |
Array<unknown>) and update the type guards that consume `dataPreview` and
`resolvedData` to perform runtime checks (typeof, Array.isArray, and property
checks) before treating them as specific types; ensure components/functions
referencing `dataPreview` or `resolvedData` (e.g., rendering previews or calling
into lazy-load logic) narrow the `unknown` via user-defined type predicates or
inline checks so the code compiles under TS strict mode.
src/db/database.ts (1)

391-404: Keep idx_sessions_last_access usable during cleanup.

Wrapping last_access in strftime() prevents SQLite from using the index created on that column, so session cleanup degrades to a full scan as the table grows. Compare against a preformatted timestamp string instead of transforming the indexed column in the predicate.

Suggested fix
     const nowSeconds = Math.floor(Date.now() / 1000);
     const ttlSeconds = Math.max(1, Math.floor(ttlMs / 1000));
     const cutoff = nowSeconds - ttlSeconds;
+    const cutoffIso = new Date(cutoff * 1000).toISOString().slice(0, 19).replace('T', ' ');
@@
       const rows = db
         .prepare(
-          `SELECT id FROM sessions WHERE CAST(strftime('%s', last_access) AS INTEGER) <= ?`
+          `SELECT id FROM sessions WHERE last_access <= ?`
         )
-        .all(cutoff) as { id: string }[];
+        .all(cutoffIso) as { id: string }[];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/db/database.ts` around lines 391 - 404, The SELECT uses
CAST(strftime('%s', last_access) AS INTEGER) which prevents SQLite from using
the idx_sessions_last_access index; change the predicate to compare the indexed
last_access column directly to a preformatted timestamp string. Compute
cutoffISO from cutoff (e.g., new Date(cutoff * 1000).toISOString()) and replace
the query in the sessions cleanup block (the prepare/SELECT id FROM sessions ...
statement) to "WHERE last_access <= ?" and pass cutoffISO as the bound, keeping
the same batching logic (CHUNK, BEGIN IMMEDIATE, transaction) around the
deletion loop.
tests/routes/nip46.spec.ts (1)

34-76: Please add new NIP-46 route coverage next to src/routes/nip46.ts.

This case is useful, but adding more route tests under tests/routes/ keeps drifting from the repo’s co-location rule. Prefer moving this spec to src/routes/nip46.spec.ts (or .test.ts) before growing it further.

As per coding guidelines: **/*.{test,spec}.ts: Co-locate backend tests with code and name them feature.test.ts or feature.spec.ts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/routes/nip46.spec.ts` around lines 34 - 76, Move the new NIP-46 route
spec so it is co-located with the route implementation: create nip46.spec.ts (or
nip46.test.ts) next to the nip46.ts implementation and remove the copy under
tests/routes; update imports inside the spec that reference the project root or
absolute paths so they import relative to the route module (preserve the use of
handleNip46Route and any database setup/teardown code), and ensure
runRouteScript or script bootstrap logic still resolves in the new location and
tests still call handleNip46Route with the same context/auth parameters.
src/routes/sign.ts (1)

6-15: Consider replacing any[] with a stricter type for tags.

The tags field uses any[] which conflicts with TypeScript strict mode guidelines. Since Nostr event tags are arrays of strings, consider using string[][] for better type safety.

♻️ Proposed fix
 type SignRequestBody = {
   message?: string; // 32-byte hex event id
   event?: Partial<EventTemplate> & {
     kind: number;
     created_at: number;
     content: string;
-    tags: any[];
+    tags: string[][];
     pubkey?: string; // Optional pubkey for event hashing
   };
 };

As per coding guidelines: "TypeScript strict mode; explicit types, avoid any".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/sign.ts` around lines 6 - 15, The SignRequestBody type uses tags:
any[] which breaks strict TypeScript rules; change the tags type to a nested
string array (string[][]) to reflect Nostr event tags being arrays of strings
and improve type safety—update the SignRequestBody definition (and any
referenced Partial<EventTemplate> shape if it also declares tags) to use tags:
string[][] and adjust any callers or tests that construct events to satisfy the
new type.
src/routes/admin.ts (1)

291-291: lastUsedIp hardcoded to null in API response.

This appears intentional to avoid exposing client IP addresses in the admin listing. If this is a deliberate privacy decision, consider adding a brief comment to document the rationale.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/admin.ts` at line 291, The API response currently sets lastUsedIp:
null (in the admin listing) to avoid exposing client IPs; add a one-line comment
next to the lastUsedIp assignment (the property named lastUsedIp in
src/routes/admin.ts) documenting this privacy decision and the intentional
omission, or if this was accidental, replace the null with the correct source
(e.g., pull from the session or user record) and log a brief note about
consent/visibility; keep the comment concise and reference lastUsedIp so future
reviewers understand the rationale.
src/db/nip46.ts (1)

431-452: Keep the cursor params strongly typed.

params only needs number | bigint | string, so widening it to any[] weakens this pagination path for no benefit.

♻️ Suggested typing
-  const params: any[] = [userId]
+  const params: Array<number | bigint | string> = [userId]
Based on learnings, "Applies to **/*.{ts,tsx} : TypeScript strict mode; explicit types, avoid `any`".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/db/nip46.ts` around lines 431 - 452, Change the untyped params array to a
strongly typed array (e.g. (number|bigint|string)[]) instead of any[] in
src/db/nip46.ts so the pagination/cursor path stays type-safe; update the
declaration of params and ensure all pushes (params.push(...statuses),
params.push(before.createdAt, before.createdAt, before.id)) and the final call
stmt.all(...params, limit) are consistent with that type (casting inputs only if
necessary) in the function that builds the WHERE clause and returns
Nip46RequestRecord[].

Comment thread .github/workflows/ci.yml Outdated
Comment thread .github/workflows/release.yml
Comment thread CHANGELOG.md Outdated
Comment thread frontend/components/Configure.tsx Outdated
Comment thread frontend/components/Configure.tsx Outdated
Comment thread src/routes/nip04.ts Outdated
Comment thread src/routes/utils.ts
Comment thread src/utils/rate-limiter.ts Outdated
Comment thread tests/routes/helpers/script-runner.ts
Comment thread tests/routes/user-peers.spec.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
frontend/components/Configure.tsx (2)

322-340: ⚠️ Potential issue | 🟠 Major

Stale closure: isDirty is captured at effect setup, not at focus time.

The dirty check is computed when the effect runs, but handleFocus captures that stale value. If the user edits the form after the effect setup, the focus handler will still use the old isDirty value.

🐛 Proposed fix: compute dirty state inside the callback
   useEffect(() => {
     if (!showAdvanced) return;
 
-    const isDirty = JSON.stringify(advancedSettings) !== JSON.stringify(originalAdvancedSettings);
-    const reloadAdvancedSettings = () => {
-      if (isDirty) return;
-      void loadAdvancedSettings(isHeadlessMode);
-    };
-    const handleFocus = () => { reloadAdvancedSettings() };
-    reloadAdvancedSettings();
+    const reloadIfClean = () => {
+      const isDirty = JSON.stringify(advancedSettings) !== JSON.stringify(originalAdvancedSettings);
+      if (isDirty) return;
+      void loadAdvancedSettings(isHeadlessMode);
+    };
+    const handleFocus = () => { reloadIfClean() };
+    reloadIfClean();
     window.addEventListener('focus', handleFocus);
     return () => {
       window.removeEventListener('focus', handleFocus);

However, this still won't work because advancedSettings and originalAdvancedSettings would be stale in the closure. A cleaner approach is to use refs to track the latest values:

+  const advancedSettingsRef = useRef(advancedSettings);
+  const originalAdvancedSettingsRef = useRef(originalAdvancedSettings);
+  useEffect(() => { advancedSettingsRef.current = advancedSettings; }, [advancedSettings]);
+  useEffect(() => { originalAdvancedSettingsRef.current = originalAdvancedSettings; }, [originalAdvancedSettings]);
+
   useEffect(() => {
     if (!showAdvanced) return;
 
-    const isDirty = JSON.stringify(advancedSettings) !== JSON.stringify(originalAdvancedSettings);
-    const reloadAdvancedSettings = () => {
-      if (isDirty) return;
+    const reloadIfClean = () => {
+      const isDirty = JSON.stringify(advancedSettingsRef.current) !== JSON.stringify(originalAdvancedSettingsRef.current);
+      if (isDirty) return;
       void loadAdvancedSettings(isHeadlessMode);
     };
-    const handleFocus = () => { reloadAdvancedSettings() };
-    reloadAdvancedSettings();
+    const handleFocus = () => { reloadIfClean() };
+    reloadIfClean();
     window.addEventListener('focus', handleFocus);
     ...
-  }, [showAdvanced, isHeadlessMode, loadAdvancedSettings, advancedSettings, originalAdvancedSettings]);
+  }, [showAdvanced, isHeadlessMode, loadAdvancedSettings]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/Configure.tsx` around lines 322 - 340, The effect
captures a stale isDirty value so handleFocus may use outdated state; fix by
computing the dirty check at focus time (or by reading the latest values from
refs) instead of using the isDirty variable captured at effect setup: update
handleFocus/reloadAdvancedSettings to compute
JSON.stringify(advancedSettingsRef.current) !==
JSON.stringify(originalAdvancedSettingsRef.current) on invocation (maintain refs
advancedSettingsRef and originalAdvancedSettingsRef that are kept in sync with
advancedSettings and originalAdvancedSettings), and keep using
loadAdvancedSettings and loadAdvancedSettingsRef for the actual reload logic.

306-306: ⚠️ Potential issue | 🟡 Minor

Avoid any type per coding guidelines.

The mock array uses any[]. Even for placeholder code, prefer explicit typing.

💡 Proposed fix
-        const shares: any[] = []; // Empty array for now, can be populated for testing
+        const shares: Array<{ name: string }> = []; // Empty array for now, can be populated for testing

As per coding guidelines: "Enable TypeScript strict mode, declare explicit types, and avoid any"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/Configure.tsx` at line 306, The mock shares array uses
the any[] type; replace it with an explicit, strict type and avoid any. Define
or import the appropriate interface (e.g., Share or SharePlaceholder) and
declare shares as Share[] (or Array<Share>), initializing it as an empty array
(const shares: Share[] = []); if no real shape exists yet, create a minimal
local interface (e.g., interface Share { id: string; name?: string } ) and use
that instead of any to satisfy strict TypeScript rules in Configure.tsx.
♻️ Duplicate comments (6)
.github/workflows/ci.yml (1)

5-7: ⚠️ Potential issue | 🟠 Major

CI triggers still exclude the active target branch (master).

This PR targets master, but the workflow listens only on main/dev. Pushes and PRs targeting master will skip CI.

🔧 Suggested fix
 on:
   push:
-    branches: [ main, dev ]
+    branches: [ main, master, dev ]
   pull_request:
-    branches: [ main, dev ]
+    branches: [ main, master, dev ]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/ci.yml around lines 5 - 7, The workflow's on.branches
lists only "main" and "dev", so CI won't run for pushes or PRs targeting
"master"; update both branch arrays under the triggers "branches: [ main, dev ]"
and "pull_request: branches: [ main, dev ]" to include "master" (e.g., branches:
[ main, dev, master ]) so the CI runs for pushes and PRs against master as well.
.github/workflows/release.yml (2)

14-14: ⚠️ Potential issue | 🟠 Major

Release job can be skipped entirely if the active release branch is still master.

This gate only runs on refs/heads/main. Manual releases from master will skip the release and dependent docker jobs.

🔧 Suggested fix to support both branches
-    if: github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch'
+    if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') && github.event_name == 'workflow_dispatch'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/release.yml at line 14, The release job condition
currently only matches "if: github.ref == 'refs/heads/main' && github.event_name
== 'workflow_dispatch'", which causes manual releases from a 'master' branch to
be skipped; update that conditional to allow both branches (e.g., check
github.ref equals 'refs/heads/main' OR 'refs/heads/master' or otherwise match
both refs) so the release job (and its dependent docker jobs) runs for manual
dispatches from either branch.

119-126: ⚠️ Potential issue | 🟠 Major

Changelog insertion target does not match dated headings.

The heading is created at line 95 as ## [version] - date, but the awk condition at line 121 uses exact equality $0 == target against ## [${VERSION}] (without date). The insertion block will never be triggered.

🔧 Suggested fix using prefix match
         # Insert changes into changelog after the current version heading
         awk -v target="## [${VERSION}]" -v insert_file="$temp_changelog" '
           { print }
-          $0 == target && !inserted {
+          index($0, target) == 1 && !inserted {
             while ((getline line < insert_file) > 0) print line
             close(insert_file)
             inserted = 1
           }
         ' CHANGELOG.md > CHANGELOG.md.tmp
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/release.yml around lines 119 - 126, The awk insertion
never fires because the script compares the whole line to "## [${VERSION}]"
(variable target) but the actual heading includes a date; update the awk
condition in the block that builds CHANGELOG.md.tmp so it detects a heading
prefix rather than exact equality — e.g., replace the equality check `$0 ==
target` with a starts-with or regex test (using index($0, target) == 1 or $0 ~
("^" target)) while keeping the inserted flag and using insert_file
(temp_changelog) and output file (CHANGELOG.md.tmp) unchanged.
src/db/nip46.ts (1)

705-712: ⚠️ Potential issue | 🟠 Major

Verify that session creation still emits a created event.

This limiter now counts only nip46_session_events.event_type = 'created', but the creation flow visible in this file still logs upsert. If no route/service writes an immutable created record on successful session creation, the rate limit becomes a permanent zero.

Expected result: at least one session-creation path writes logSessionEvent(..., 'created', ...) or inserts event_type = 'created' into nip46_session_events.

#!/bin/bash
set -euo pipefail

ast-grep --pattern "logSessionEvent($$$, 'created', $$$)"
ast-grep --pattern 'logSessionEvent($$$, "created", $$$)'
rg -n -C3 "nip46_session_events|event_type = 'created'|event_type = \"created\"" src tests
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/db/nip46.ts` around lines 705 - 712, The rate limiter query in
nip46_session_events filters only event_type = 'created' but the session
creation path currently emits 'upsert' events (no immutable 'created' rows), so
either ensure the creation flow writes a 'created' event or update the limiter
to count the actual event used; locate the session creation/upsert flow (search
for logSessionEvent or any upsertSession/createSession routine in this file) and
either add a logSessionEvent(..., 'created', ...) call when a new session is
successfully created or change the COUNT query on nip46_session_events to
include event_type = 'upsert' (or both 'created' and 'upsert') so the limiter
reflects real events.
scripts/patch-zod-compat.mjs (2)

27-35: ⚠️ Potential issue | 🟠 Major

Validate the actual Zod targets, not just the package directory.

Lines 32-35 assume ./v3/external.js and ./v3/external.cjs exist, but the guard on Lines 27-29 only checks node_modules/zod. If the installed Zod layout changes, this still writes broken entrypoints and postinstall stays green until build/runtime. At minimum, fail fast on those hardcoded targets too.

Suggested change
-if (!existsSync(ZOD_DIR)) {
-  console.error('[patch-zod-compat] zod directory not found')
+if (
+  !existsSync(ZOD_DIR) ||
+  !existsSync(join(ZOD_DIR, 'v3', 'external.js')) ||
+  !existsSync(join(ZOD_DIR, 'v3', 'external.cjs'))
+) {
+  console.error('[patch-zod-compat] zod compatibility targets not found')
   process.exit(1)
 } else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/patch-zod-compat.mjs` around lines 27 - 35, The script currently only
checks existence of ZOD_DIR before writing entry files but must also validate
the specific target files it will overwrite; add existence checks for the
concrete targets (e.g., the paths you will create via join(ZOD_DIR, 'index.js'),
join(ZOD_DIR, 'index.cjs'), and join(ZOD_DIR, 'index.d.cts') or the upstream
files it expects like './v3/external.js' and './v3/external.cjs') and abort with
a clear error if any are missing instead of proceeding to call ensureFile;
update the guard around ZOD_DIR to also verify those target files exist via
existsSync and call process.exit(1) with an error message before calling
ensureFile(…, esmStub/cjsStub/cjsDeclarationStub).

42-55: ⚠️ Potential issue | 🟠 Major

Fail only when schemas are neither patched nor already compatible.

Lines 43-50 only rewrite one exact import spelling. If @cmdcode/nostr-connect changes that string or ships an already-fixed variant, this block becomes a successful no-op with no signal either way. Track whether each file was already compatible or patched, and exit non-zero only when neither state is true.

Suggested change
 } else {
   try {
-  const schemaFiles = readdirSync(NOSTR_SCHEMA_DIR).filter(name => name.endsWith('.js'))
-  for (const file of schemaFiles) {
-    const fullPath = join(NOSTR_SCHEMA_DIR, file)
-    const current = readFileSync(fullPath, 'utf8')
-    const target = "import { zod as z } from '@vbyte/micro-lib/schema';"
-    if (current.includes("import { z } from 'zod';")) {
-      const updated = current.replaceAll("import { z } from 'zod';", target)
-      writeFileSync(fullPath, updated)
+    const schemaFiles = readdirSync(NOSTR_SCHEMA_DIR).filter(name => name.endsWith('.js'))
+    const target = "import { zod as z } from '@vbyte/micro-lib/schema';"
+    let compatibleFiles = 0
+
+    for (const file of schemaFiles) {
+      const fullPath = join(NOSTR_SCHEMA_DIR, file)
+      const current = readFileSync(fullPath, 'utf8')
+
+      if (current.includes(target)) {
+        compatibleFiles += 1
+        continue
+      }
+
+      if (current.includes("import { z } from 'zod';")) {
+        const updated = current.replaceAll("import { z } from 'zod';", target)
+        writeFileSync(fullPath, updated)
+        compatibleFiles += 1
+      }
+    }
+
+    if (compatibleFiles === 0) {
+      console.error('[patch-zod-compat] No compatible nostr-connect schema imports found')
+      process.exit(1)
     }
-  }
   } catch (error) {
     console.error('[patch-zod-compat] Failed to patch nostr-connect schemas:', error)
     process.exit(1)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/patch-zod-compat.mjs` around lines 42 - 55, The patch should detect
per-file whether it was already compatible or got patched and only exit non-zero
when a file is neither; inside the loop over schemaFiles (variables:
schemaFiles, fullPath, current, target) check if current.includes(target) and
mark as "compatible", else if current.includes("import { z } from 'zod';")
perform the replace/write (as you already do) and mark as "patched", otherwise
record an "unknown" file; after the loop, if any unknown files exist log an
error and process.exit(1), otherwise proceed normally.
🧹 Nitpick comments (5)
src/nip46/index.ts (1)

13-19: Keep areInitOptionsEquivalent from drifting from InitOptions.

This works for the current three fields, but any future InitOptions addition has to be remembered here too. Missing one would incorrectly reuse the existing singleton with stale config. Consider centralizing the compared fields so the type and the equivalence check stay in sync.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nip46/index.ts` around lines 13 - 19, The equivalence check in
areInitOptionsEquivalent will drift from the InitOptions type; centralize the
compared fields by declaring a single source of truth (e.g., an exported
constant array of keys typed as (keyof InitOptions)[]) and use that array in
areInitOptionsEquivalent to iterate and compare each property, so future
additions to InitOptions only require updating that one array (reference
InitOptions and areInitOptionsEquivalent by name to locate the change).
.github/workflows/ci.yml (1)

87-113: Robust retry logic for security audit.

The 3-attempt retry with network-error detection is a good improvement for CI reliability. One minor observation: the last_attempt variable is set inside the loop but referenced after it exits.

🔧 Minor fix for variable initialization
         audit_log="$(mktemp)"
         last_exit=0
+        last_attempt=0
         for attempt in 1 2 3; do
+          last_attempt=$attempt
           if bun audit >"$audit_log" 2>&1; then
             cat "$audit_log"
             rm -f "$audit_log"
             exit 0
           else
             audit_exit=$?
             last_exit=$audit_exit
-            last_attempt=$attempt
             if [ "$attempt" -lt 3 ]; then
               echo "bun audit failed (attempt $attempt), retrying..."
               sleep 5
             fi
           fi
         done
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/ci.yml around lines 87 - 113, Initialize last_attempt
before the retry loop so it is always defined when referenced after the loop;
set last_attempt=0 (or a sensible default) alongside last_exit and audit_log
initialization, keeping the retry loop (for attempt in 1 2 3) and subsequent
logic that reads last_attempt, audit_output, and the network/registry grep
checks unchanged.
src/node/manager.ts (3)

1449-1454: Missing semicolon.

Line 1450 is missing a semicolon after the function call. While JavaScript's ASI will handle this, explicit semicolons improve consistency.

♻️ Add semicolon
-      const seq = opts?.persist?.(logEntry)
+      const seq = opts?.persist?.(logEntry);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/node/manager.ts` around lines 1449 - 1454, The try block that calls
opts?.persist?.(logEntry) assigns the result to seq but is missing a trailing
semicolon; update the call in the try block (where seq is declared and used to
set logEntry.seq and logEntry.id) by adding a semicolon after the expression to
maintain consistent statement termination for the seq assignment in manager.ts
(look for the try block referencing opts?.persist?.(logEntry), seq, and
logEntry).

733-737: Redundant else branch.

Both branches assign the same value to client._relays. The if check is unnecessary.

♻️ Suggested simplification
-  if (Array.isArray(client._relays)) {
-    client._relays = [...filteredRelays];
-  } else {
-    client._relays = [...filteredRelays];
-  }
+  client._relays = [...filteredRelays];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/node/manager.ts` around lines 733 - 737, The if/else block redundantly
assigns the same value to client._relays in both branches; remove the
conditional and replace both branches with a single assignment: set
client._relays = [...filteredRelays] (referencing client._relays and
filteredRelays) so the code is simplified while preserving behavior.

690-693: Order-sensitive relay comparison may cause unnecessary updates.

sameRelayList compares arrays element-by-element, so ['a', 'b'] and ['b', 'a'] are considered different. If relay order isn't semantically significant, this could cause unnecessary reconciliation or skip valid updates when order differs.

Consider sorting both arrays before comparison if order doesn't matter:

♻️ Suggested fix for order-independent comparison
 function sameRelayList(a: string[], b: string[]): boolean {
   if (a.length !== b.length) return false;
-  return a.every((relay, index) => relay === b[index]);
+  const sortedA = [...a].sort();
+  const sortedB = [...b].sort();
+  return sortedA.every((relay, index) => relay === sortedB[index]);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/node/manager.ts` around lines 690 - 693, sameRelayList currently does an
order-sensitive element-by-element comparison which flags ['a','b'] and
['b','a'] as different; update the sameRelayList function to perform an
order-independent comparison (e.g., sort both arrays then compare lengths and
elements, or compare as Sets) so relay lists with same members but different
order are treated as equal; ensure you still return false for differing lengths
and keep the function name sameRelayList for easy locating.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/db/database.ts`:
- Around line 30-31: DB_DIR/DB_FILE initialization allows a bare relative
filename (e.g., "main" or "main.db") to produce path.dirname(envPath) === '.'
which can cause ensureDataDirSecure() to chmod the repository root; detect the
bare-file case (when isEnvPathFile is true but path.dirname(envPath) === '.' or
envPath contains no path separators) and handle it by either treating DB_DIR as
the defaultDbDir or setting a flag to skip directory hardening, then build
DB_FILE from that safe DB_DIR (or skip calling ensureDataDirSecure for the
bare-file case). Update the logic around DB_DIR, DB_FILE, and the call to
ensureDataDirSecure so ensureDataDirSecure is not invoked on '.'; reference
variables/functions: envPath, isEnvPathFile, DB_DIR, DB_FILE,
ensureDataDirSecure.

In `@src/db/migrations/20260306_0010_harden_nip46_relays_and_requests.sql`:
- Around line 30-31: The index idx_nip46_requests_dedupe on table nip46_requests
is non-unique so it doesn't prevent concurrent duplicate pending requests;
change it to a UNIQUE PARTIAL INDEX covering (user_id, session_pubkey,
client_request_id) with a WHERE clause restricting to pending rows (e.g. WHERE
status = 'pending') so only one pending row can exist, and update the nip46
bootstrap schema to create the identical unique partial index (same name and
predicate) so the runtime schema mirrors the migration.

In `@src/db/nip46.ts`:
- Around line 382-389: The insert currently calls normalizeClientRequestId with
null when params.payload is a string, causing client_request_id to be NULL;
update the logic around normalizeClientRequestId and payload handling in the
nip46 request creation so string payloads are parsed (JSON.parse) to extract the
id before calling normalizeClientRequestId, falling back to a reject/validation
error if parsing fails; ensure the same parsed object or original string is used
as the stored payload value in the INSERT into nip46_requests so
client_request_id reflects the request content for both object and string
payloads.

In `@src/nip46/index.ts`:
- Around line 23-29: The reinit path fires service.stop() but immediately
constructs a new Nip46Service, allowing old and new instances to overlap; change
this to await the stop before creating the new singleton: replace the
fire-and-forget void service.stop().catch(...) with an awaited stop call (try {
await service.stop() } catch (error) { console.warn(..., error) }), ensure the
enclosing function is async if it isn’t, and only assign service = new
Nip46Service(opts) and update serviceInitOptions after the awaited stop
completes so the old instance is fully torn down before publishing the new one;
keep the existing logging behavior on stop failure.

In `@tests/routes/database-path.spec.ts`:
- Around line 12-30: The temp directory created by fs.mkdtempSync (tmpDir) is
never removed; update the child script so that after calling
database.default.exec and awaiting database.closeDatabase() you remove tmpDir
(and its contents) — e.g., in the outer finally block call a synchronous or
awaited recursive removal on tmpDir (using fs.rmSync or fs.promises.rm with {
recursive: true, force: true }) before process.exit(0); ensure removal runs even
if closeDatabase throws by placing it in the outer finally and reference
tmpDir/dbPath and database.closeDatabase so you clean up the igloo-db-path-*
folder on every run.

In `@tests/routes/headless-optimizations.spec.ts`:
- Around line 7-15: The afterEach hook that deletes process.env keys (the
afterEach block removing NODE_ENV, HEADLESS, AUTH_ENABLED, API_KEY,
RATE_LIMIT_ENABLED, SESSION_SECRET, SKIP_STARTUP_ECHO) must be removed because
the tests mutate env only in the spawned child process; delete that hook so the
parent test runner environment isn't clobbered, or if you need a cleanup
placeholder replace it with a no-op that does not modify process.env in the
parent process. Ensure you edit the afterEach definition to either be removed
entirely or changed to an empty function so only the child process environment
is affected.

---

Outside diff comments:
In `@frontend/components/Configure.tsx`:
- Around line 322-340: The effect captures a stale isDirty value so handleFocus
may use outdated state; fix by computing the dirty check at focus time (or by
reading the latest values from refs) instead of using the isDirty variable
captured at effect setup: update handleFocus/reloadAdvancedSettings to compute
JSON.stringify(advancedSettingsRef.current) !==
JSON.stringify(originalAdvancedSettingsRef.current) on invocation (maintain refs
advancedSettingsRef and originalAdvancedSettingsRef that are kept in sync with
advancedSettings and originalAdvancedSettings), and keep using
loadAdvancedSettings and loadAdvancedSettingsRef for the actual reload logic.
- Line 306: The mock shares array uses the any[] type; replace it with an
explicit, strict type and avoid any. Define or import the appropriate interface
(e.g., Share or SharePlaceholder) and declare shares as Share[] (or
Array<Share>), initializing it as an empty array (const shares: Share[] = []);
if no real shape exists yet, create a minimal local interface (e.g., interface
Share { id: string; name?: string } ) and use that instead of any to satisfy
strict TypeScript rules in Configure.tsx.

---

Duplicate comments:
In @.github/workflows/ci.yml:
- Around line 5-7: The workflow's on.branches lists only "main" and "dev", so CI
won't run for pushes or PRs targeting "master"; update both branch arrays under
the triggers "branches: [ main, dev ]" and "pull_request: branches: [ main, dev
]" to include "master" (e.g., branches: [ main, dev, master ]) so the CI runs
for pushes and PRs against master as well.

In @.github/workflows/release.yml:
- Line 14: The release job condition currently only matches "if: github.ref ==
'refs/heads/main' && github.event_name == 'workflow_dispatch'", which causes
manual releases from a 'master' branch to be skipped; update that conditional to
allow both branches (e.g., check github.ref equals 'refs/heads/main' OR
'refs/heads/master' or otherwise match both refs) so the release job (and its
dependent docker jobs) runs for manual dispatches from either branch.
- Around line 119-126: The awk insertion never fires because the script compares
the whole line to "## [${VERSION}]" (variable target) but the actual heading
includes a date; update the awk condition in the block that builds
CHANGELOG.md.tmp so it detects a heading prefix rather than exact equality —
e.g., replace the equality check `$0 == target` with a starts-with or regex test
(using index($0, target) == 1 or $0 ~ ("^" target)) while keeping the inserted
flag and using insert_file (temp_changelog) and output file (CHANGELOG.md.tmp)
unchanged.

In `@scripts/patch-zod-compat.mjs`:
- Around line 27-35: The script currently only checks existence of ZOD_DIR
before writing entry files but must also validate the specific target files it
will overwrite; add existence checks for the concrete targets (e.g., the paths
you will create via join(ZOD_DIR, 'index.js'), join(ZOD_DIR, 'index.cjs'), and
join(ZOD_DIR, 'index.d.cts') or the upstream files it expects like
'./v3/external.js' and './v3/external.cjs') and abort with a clear error if any
are missing instead of proceeding to call ensureFile; update the guard around
ZOD_DIR to also verify those target files exist via existsSync and call
process.exit(1) with an error message before calling ensureFile(…,
esmStub/cjsStub/cjsDeclarationStub).
- Around line 42-55: The patch should detect per-file whether it was already
compatible or got patched and only exit non-zero when a file is neither; inside
the loop over schemaFiles (variables: schemaFiles, fullPath, current, target)
check if current.includes(target) and mark as "compatible", else if
current.includes("import { z } from 'zod';") perform the replace/write (as you
already do) and mark as "patched", otherwise record an "unknown" file; after the
loop, if any unknown files exist log an error and process.exit(1), otherwise
proceed normally.

In `@src/db/nip46.ts`:
- Around line 705-712: The rate limiter query in nip46_session_events filters
only event_type = 'created' but the session creation path currently emits
'upsert' events (no immutable 'created' rows), so either ensure the creation
flow writes a 'created' event or update the limiter to count the actual event
used; locate the session creation/upsert flow (search for logSessionEvent or any
upsertSession/createSession routine in this file) and either add a
logSessionEvent(..., 'created', ...) call when a new session is successfully
created or change the COUNT query on nip46_session_events to include event_type
= 'upsert' (or both 'created' and 'upsert') so the limiter reflects real events.

---

Nitpick comments:
In @.github/workflows/ci.yml:
- Around line 87-113: Initialize last_attempt before the retry loop so it is
always defined when referenced after the loop; set last_attempt=0 (or a sensible
default) alongside last_exit and audit_log initialization, keeping the retry
loop (for attempt in 1 2 3) and subsequent logic that reads last_attempt,
audit_output, and the network/registry grep checks unchanged.

In `@src/nip46/index.ts`:
- Around line 13-19: The equivalence check in areInitOptionsEquivalent will
drift from the InitOptions type; centralize the compared fields by declaring a
single source of truth (e.g., an exported constant array of keys typed as (keyof
InitOptions)[]) and use that array in areInitOptionsEquivalent to iterate and
compare each property, so future additions to InitOptions only require updating
that one array (reference InitOptions and areInitOptionsEquivalent by name to
locate the change).

In `@src/node/manager.ts`:
- Around line 1449-1454: The try block that calls opts?.persist?.(logEntry)
assigns the result to seq but is missing a trailing semicolon; update the call
in the try block (where seq is declared and used to set logEntry.seq and
logEntry.id) by adding a semicolon after the expression to maintain consistent
statement termination for the seq assignment in manager.ts (look for the try
block referencing opts?.persist?.(logEntry), seq, and logEntry).
- Around line 733-737: The if/else block redundantly assigns the same value to
client._relays in both branches; remove the conditional and replace both
branches with a single assignment: set client._relays = [...filteredRelays]
(referencing client._relays and filteredRelays) so the code is simplified while
preserving behavior.
- Around line 690-693: sameRelayList currently does an order-sensitive
element-by-element comparison which flags ['a','b'] and ['b','a'] as different;
update the sameRelayList function to perform an order-independent comparison
(e.g., sort both arrays then compare lengths and elements, or compare as Sets)
so relay lists with same members but different order are treated as equal;
ensure you still return false for differing lengths and keep the function name
sameRelayList for easy locating.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 27eb5ca7-5041-4a1f-89b1-b1c905fdd791

📥 Commits

Reviewing files that changed from the base of the PR and between f8a8e73 and 4fd8dd0.

📒 Files selected for processing (23)
  • .github/workflows/ci.yml
  • .github/workflows/release.yml
  • CHANGELOG.md
  • frontend/components/Configure.tsx
  • frontend/components/ui/button.tsx
  • scripts/patch-zod-compat.mjs
  • src/db/database.ts
  • src/db/migrations/20260306_0010_harden_nip46_relays_and_requests.sql
  • src/db/nip46.ts
  • src/nip46/index.ts
  • src/node/manager.ts
  • src/routes/nip04.ts
  • src/routes/nip44.ts
  • src/routes/peers.ts
  • src/routes/utils.ts
  • src/utils/rate-limiter.ts
  • tests/routes/database-path.spec.ts
  • tests/routes/headless-optimizations.spec.ts
  • tests/routes/helpers/script-runner.spec.ts
  • tests/routes/helpers/script-runner.ts
  • tests/routes/nip46.spec.ts
  • tests/routes/rate-limiter.spec.ts
  • tests/routes/user-peers.spec.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/routes/nip44.ts
  • src/utils/rate-limiter.ts
  • src/routes/utils.ts
  • frontend/components/ui/button.tsx
  • tests/routes/nip46.spec.ts

Comment thread src/db/database.ts Outdated
Comment thread src/db/migrations/20260306_0010_harden_nip46_relays_and_requests.sql Outdated
Comment thread src/db/nip46.ts
Comment thread src/db/nip46.ts Outdated
Comment thread src/nip46/index.ts Outdated
Comment thread tests/routes/database-path.spec.ts
Comment thread tests/routes/headless-optimizations.spec.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

♻️ Duplicate comments (1)
src/server.ts (1)

658-662: ⚠️ Potential issue | 🟠 Major

Disable query-string WebSocket credentials by default.

Line 660 still enables ?apiKey= / ?sessionId= auth when ALLOW_QUERY_CREDENTIALS is unset. That keeps credential material flowing through URLs, access logs, browser history, and intermediary caches unless operators explicitly turn it off. Default this to false and only opt in on an explicit allow value.

🔐 Suggested hardening
 const parsedAllowQueryCredentials = (process.env.ALLOW_QUERY_CREDENTIALS ?? '').trim().toLowerCase();
-// Backward-compatible default: allow legacy ?apiKey= / ?sessionId= WS auth unless explicitly disabled.
-const WS_ALLOW_QUERY_CREDENTIALS = parsedAllowQueryCredentials === ''
-  ? true
-  : !['0', 'false', 'no', 'off'].includes(parsedAllowQueryCredentials);
+const WS_ALLOW_QUERY_CREDENTIALS = ['1', 'true', 'yes', 'on'].includes(parsedAllowQueryCredentials);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server.ts` around lines 658 - 662, The current logic sets
WS_ALLOW_QUERY_CREDENTIALS to true when process.env.ALLOW_QUERY_CREDENTIALS is
unset, which keeps ?apiKey / ?sessionId auth enabled by default; change the
default to false so query-string credentials are disabled unless explicitly
allowed. Update the computation around parsedAllowQueryCredentials and
WS_ALLOW_QUERY_CREDENTIALS so that an empty ALLOW_QUERY_CREDENTIALS yields false
(not true), and only specific explicit truthy values (e.g.,
'1','true','yes','on') set WS_ALLOW_QUERY_CREDENTIALS to true; reference the
existing symbols parsedAllowQueryCredentials, WS_ALLOW_QUERY_CREDENTIALS and
process.env.ALLOW_QUERY_CREDENTIALS when making this change.
🧹 Nitpick comments (1)
src/db/nip46.ts (1)

417-423: Avoid the new any escapes in this path.

payloadObject: Record<string, any> and const params: any[] punch holes in the strict parsing/query code you just added. Record<string, unknown> plus a typed params tuple keeps this path type-safe without losing flexibility. As per coding guidelines, "TypeScript strict mode; explicit types, avoid any".

Also applies to: 505-506

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/db/nip46.ts` around lines 417 - 423, Change the loose any types to strict
unknowns: replace the declaration payloadObject: Record<string, any> with
payloadObject: Record<string, unknown> and update the params typing (where
params is currently any[]) to a properly typed tuple or union that matches
expected parameter shapes instead of any[]; ensure downstream uses of
payloadObject and params do appropriate type-narrowing (e.g., typeof checks and
Array.isArray) before accessing properties so the code compiles in strict mode
and preserves the existing runtime behavior in the functions referencing
params.payload and the later occurrence around lines 505-506.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/db/nip46.ts`:
- Around line 780-789: countUserSessionsInWindow() relies on the 'created' event
written to nip46_session_events but the POST /sessions handler calls
upsertSession() and writes that event afterwards while ignoring errors, allowing
quota bypass; fix by making the ledger write atomic with session creation: wrap
upsertSession() and the INSERT into nip46_session_events (the 'created' event)
in a single database transaction or change the POST /sessions handler to abort
and return an error if the ledger INSERT fails, ensuring the write to
nip46_session_events succeeds whenever upsertSession() succeeds.

In `@src/nip46/index.ts`:
- Around line 21-33: The initNip46Service function can race during
reinitialization because callers see the old service while one caller is
awaiting service.stop(); fix by serializing reinitialization: introduce a
module-level init promise or mutex (e.g., initPromise or initLock) and in
initNip46Service check and await that promise when present before proceeding,
set the promise immediately when starting the stop/create sequence, perform
await service.stop(), construct new Nip46Service(opts), assign service and
serviceInitOptions, then resolve/clear the init promise; reference symbols:
initNip46Service, service, serviceInitOptions, Nip46Service,
areInitOptionsEquivalent.

In `@src/server.ts`:
- Around line 225-232: The initNip46Service call is started before the database
schema/migrations finish, causing a race with initializeDatabase(); move the
initNip46Service invocation (the block using CONST.HEADLESS, addServerLog,
broadcastEvent, getNode and calling initNip46Service) so it runs only after
await initializeDatabase() has completed successfully (i.e., relocate both
occurrences to the code path immediately after initializeDatabase() resolves and
only start the service then, rather than fire-and-forget before DB init).

In `@tests/routes/headless-optimizations.spec.ts`:
- Around line 151-174: The test never triggers the startup path that calls
initNip46Service, so change the test to import the actual bootstrap/startup
module that runs the server initialization (the module that contains the if
(!HEADLESS) gating logic in server.ts) instead of directly importing
src/nip46/index.ts; set process.env.HEADLESS appropriately before that import,
call getNip46Service() (from the nip46 module) after the bootstrap import, and
assert null when HEADLESS='true' and non-null when HEADLESS='false' to ensure
the HEADLESS gate is exercised and initNip46Service() is invoked (or skipped) as
intended.

In `@tests/routes/nip46.spec.ts`:
- Around line 250-253: The test currently only checks that an index named
'idx_nip46_requests_pending_dedupe' exists and is unique; update the assertion
to verify the index is a partial (predicate) index for pending deduplication by
asserting the PRAGMA index_list entry has partial === 1, or alternatively read
sqlite_master.sql for the index definition and assert it contains "WHERE status
= 'pending' AND client_request_id IS NOT NULL"; change the hasPendingDedupe
check to inspect the index object returned by database.default.prepare("PRAGMA
index_list(nip46_requests)").all() for partial === 1 (or validate
sqlite_master.sql.sql) so the test confirms the index predicate, not just its
name or uniqueness.

---

Duplicate comments:
In `@src/server.ts`:
- Around line 658-662: The current logic sets WS_ALLOW_QUERY_CREDENTIALS to true
when process.env.ALLOW_QUERY_CREDENTIALS is unset, which keeps ?apiKey /
?sessionId auth enabled by default; change the default to false so query-string
credentials are disabled unless explicitly allowed. Update the computation
around parsedAllowQueryCredentials and WS_ALLOW_QUERY_CREDENTIALS so that an
empty ALLOW_QUERY_CREDENTIALS yields false (not true), and only specific
explicit truthy values (e.g., '1','true','yes','on') set
WS_ALLOW_QUERY_CREDENTIALS to true; reference the existing symbols
parsedAllowQueryCredentials, WS_ALLOW_QUERY_CREDENTIALS and
process.env.ALLOW_QUERY_CREDENTIALS when making this change.

---

Nitpick comments:
In `@src/db/nip46.ts`:
- Around line 417-423: Change the loose any types to strict unknowns: replace
the declaration payloadObject: Record<string, any> with payloadObject:
Record<string, unknown> and update the params typing (where params is currently
any[]) to a properly typed tuple or union that matches expected parameter shapes
instead of any[]; ensure downstream uses of payloadObject and params do
appropriate type-narrowing (e.g., typeof checks and Array.isArray) before
accessing properties so the code compiles in strict mode and preserves the
existing runtime behavior in the functions referencing params.payload and the
later occurrence around lines 505-506.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 663f0cc1-98d0-4bcc-96b5-b354e7b0e7c6

📥 Commits

Reviewing files that changed from the base of the PR and between 4fd8dd0 and 3201d6a.

📒 Files selected for processing (8)
  • src/db/database.ts
  • src/db/migrations/20260306_0010_harden_nip46_relays_and_requests.sql
  • src/db/nip46.ts
  • src/nip46/index.ts
  • src/server.ts
  • tests/routes/database-path.spec.ts
  • tests/routes/headless-optimizations.spec.ts
  • tests/routes/nip46.spec.ts

Comment thread src/db/nip46.ts
Comment thread src/nip46/index.ts Outdated
Comment thread src/server.ts Outdated
Comment thread tests/routes/headless-optimizations.spec.ts Outdated
Comment thread tests/routes/nip46.spec.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

♻️ Duplicate comments (4)
docs/openapi/openapi.json (1)

2463-2527: ⚠️ Potential issue | 🟠 Major

Publish transport_pubkey in the bundled JSON spec.

docs/openapi/openapi.yaml now models /api/nip46/transport as returning a public key, but this JSON bundle still exposes transport_sk on the GET and PUT responses. That republishes the wrong contract and will mislead generated clients.

Also applies to: 4260-4270

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/openapi/openapi.json` around lines 2463 - 2527, The JSON OpenAPI bundle
exposes the private field "transport_sk" in the GET (operationId
getNip46Transport) and PUT (operationId updateNip46Transport) responses but the
YAML model returns the public key; update the response schemas for both
operations to publish "transport_pubkey" (string) instead of "transport_sk" so
the bundled spec matches docs/openapi/openapi.yaml and generated clients get the
correct contract.
docs/openapi/openapi.yaml (1)

119-125: ⚠️ Potential issue | 🟠 Major

Remove the credential-in-URL / echoed-subprotocol guidance from /api/events.

This still tells clients to send auth material in Sec-WebSocket-Protocol values and legacy query params. That conflicts with the later “never include ... in URL query parameters” guidance and makes credential leakage through logs/intermediaries much more likely.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/openapi/openapi.yaml` around lines 119 - 125, Update the Authentication
section for the /api/events endpoint to remove any instructions that tell
clients to place credentials in the URL query params or in echoed
Sec-WebSocket-Protocol subprotocol hints (the bullet points mapping
`apikey.<TOKEN>`, `bearer.<TOKEN>`, `session.<ID>` and the legacy
`apiKey`/`sessionId` note). Instead, state that /api/events uses the same auth
mechanisms as normal HTTP requests (API key header, Authorization: Bearer,
Basic, or session cookie/header) and explicitly forbid sending secrets in URL
query strings or in Sec-WebSocket-Protocol values to avoid credential leakage;
edit the "Authentication" block in openapi.yaml for /api/events accordingly.
src/routes/nip46.ts (1)

17-27: ⚠️ Potential issue | 🟠 Major

The size guard still buffers the whole request body.

Line 25 still calls await req.text() before the limit is enforced, so requests without a trustworthy Content-Length can still force full buffering in memory. The limit needs to be enforced while reading, or the route should reject bodies whose size cannot be bounded up front.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/nip46.ts` around lines 17 - 27, parseJsonWithLimit currently calls
await req.text() which buffers the entire body before enforcing maxBytes; change
it to enforce the limit while streaming by reading from the request body reader
(use req.body / req.body.getReader()) and accumulate chunks, tracking byte
length and returning null (or throwing) as soon as accumulated bytes exceed
maxBytes; alternatively, if you prefer to reject unbounded bodies, make
parseJsonWithLimit return null immediately when content-length header is missing
or not a safe number. Update the parseJsonWithLimit function and references to
req.text() accordingly so the size check happens during streaming rather than
after full buffering.
src/db/nip46.ts (1)

156-198: ⚠️ Potential issue | 🟠 Major

Don't continue after a failed nip46_requests schema repair.

The new client_request_id column/index setup is required by createNip46Request() and getPendingNip46RequestByClientId(), but any failure in this block is swallowed by the catch below. That lets initializeNip46DB() resolve with a partially upgraded schema and pushes the failure into request handling. Please fail initialization when the nip46_requests repair path cannot complete.

Also applies to: 199-202

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/db/nip46.ts` around lines 156 - 198, The nip46_requests migration block
currently swallows errors and allows initializeNip46DB() to succeed with a
partially-upgraded schema; change the flow so any exception during the
nip46_requests repair (the code that adds client_request_id, updates values,
deletes duplicates, drops/creates indexes using db.exec and
hasTableColumn/hasIndex checks) is not absorbed by the outer catch: either
remove the local try/catch around this branch or rethrow the caught error (throw
err) so initializeNip46DB() fails; apply the same behavior to the adjacent
repair block referenced (lines ~199-202) so any failure aborts initialization
instead of letting the app continue with an inconsistent schema.
🧹 Nitpick comments (5)
src/node/manager.ts (1)

2190-2239: Consider extracting SimplePool patching to a helper.

The inline patching and restoration of SimplePool.prototype.subscribeMany works correctly but is deeply nested. Extracting this to a dedicated helper (e.g., withNormalizedSubscribeMany) would improve readability and testability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/node/manager.ts` around lines 2190 - 2239, The block that patches and
restores SimplePool.prototype.subscribeMany inside
withSimplePoolSubscribeManyLock is too nested; extract it into a helper
(suggested name: withNormalizedSubscribeMany) that accepts a callback and
performs the try/patch/restore logic around SimplePool.prototype.subscribeMany
(creating normalizedSubscribeMany, capturing originalSubscribeMany and
restoreSubscribeMany, and logging patchError via addServerLog), then call that
helper to run createConnectedNode; update usages to call
withSimplePoolSubscribeManyLock(() => withNormalizedSubscribeMany(() =>
createConnectedNode(...))) or equivalent to keep the existing locking and
error-handling semantics while improving readability and testability.
src/routes/update.ts (1)

55-59: Consider reusing the centralized boolean parser from src/const.ts.

The parseBoolean helper duplicates logic already present in src/const.ts (used for HEADLESS, SKIP_ADMIN_SECRET_VALIDATION). Extracting and reusing that pattern would reduce duplication and ensure consistent parsing across the codebase.

+import { parseEnvBoolean } from '../const.js';
+
 // Remove local parseBoolean, use imported helper instead
-function parseBoolean(value?: string): boolean {
-  if (!value) return false;
-  const trimmed = value.trim().toLowerCase();
-  return trimmed === 'true' || trimmed === '1' || trimmed === 'yes';
-}

Note: This requires exporting a helper from const.ts or creating a shared utility.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/update.ts` around lines 55 - 59, The local parseBoolean in
update.ts duplicates the boolean parsing logic already used elsewhere; replace
it by importing and using the centralized boolean parser from src/const.ts (or
move the parser into a shared utility and export it from const.ts) instead of
defining parseBoolean in update.ts; update any references inside the update
handler to call the exported helper (e.g., boolean parser name from const.ts)
and remove the local parseBoolean function to avoid duplication and ensure
consistent parsing across HEADLESS/SKIP_ADMIN_SECRET_VALIDATION and
routes/update logic.
tests/routes/update.spec.ts (2)

5-6: Derive the expected version instead of pinning 1.2.0.

This assertion will need a manual edit on every release even though the route reads package.json dynamically and the release tooling only syncs package/OpenAPI metadata.

♻️ Suggested tweak
 import { describe, expect, test } from 'bun:test';
 import { pathToFileURL } from 'url';
 import { runRouteScript } from './helpers/script-runner';
 
 const PROJECT_ROOT = pathToFileURL(process.cwd() + '/').href;
+const { version: PACKAGE_VERSION } =
+  JSON.parse(Bun.file('package.json').textSync()) as { version: string };
 
 describe('/api/update route', () => {
@@
-    expect(result.currentVersion).toBe('1.2.0');
+    expect(result.currentVersion).toBe(PACKAGE_VERSION);

Also applies to: 28-31

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/routes/update.spec.ts` around lines 5 - 6, The test currently pins the
expected version (e.g., "1.2.0") which requires manual updates; change the test
in tests/routes/update.spec.ts to derive the expected version from the
package.json that the route reads instead of hardcoding it: read/require the
repository package.json (using PROJECT_ROOT to locate it) to get
packageJson.version and use that value in the assertions that currently compare
against the pinned string (the assertions around lines referencing 28-31), so
the test uses the live package version for expectedVersion.

1-56: Please co-locate this route spec with src/routes/update.ts.

Keeping the test beside the route makes version/update behavior easier to discover with the code it validates.

As per coding guidelines, "Co-locate backend tests with code and name them feature.test.ts or feature.spec.ts".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/routes/update.spec.ts` around lines 1 - 56, Move the test file
tests/routes/update.spec.ts next to the implementation src/routes/update.ts
(e.g., src/routes/update.spec.ts or src/routes/update.test.ts) so the route and
its spec are co-located; rename the file to follow the project's convention
(feature.spec.ts or feature.test.ts) and update any import paths or test runner
helpers (runRouteScript usage) that assume its former location to use the new
relative path alongside handleUpdateRoute in src/routes/update.ts.
tests/routes/headless-optimizations.spec.ts (1)

1-265: Please split/co-locate these runtime specs with the modules they exercise.

This file mixes src/const.ts, src/routes/auth.ts, src/nip46/index.ts, and src/server.ts behavior under tests/routes/, which makes ownership and failures harder to track.

Based on learnings, "Prefer unit tests co-located with their subjects; name *.test.ts or *.spec.ts".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/routes/headless-optimizations.spec.ts` around lines 1 - 265, The test
file groups runtime specs for multiple modules making ownership unclear; split
each describe block into co-located unit/integration tests next to the module
they exercise: move SKIP_STARTUP_ECHO tests to a new const.test.ts beside
src/const.ts, move session/auth tests (referencing AUTH_CONFIG and
stopAuthCleanup) to tests next to src/routes/auth.ts, and move NIP-46 tests
(using initNip46Service and getNip46Service and server bootstrap behavior) to
tests next to src/nip46/index.ts (and a short bootstrap.integration.test.ts near
src/server.ts if needed); rename files to *.test.ts or *.spec.ts, adjust imports
to use relative paths and retain runRouteScript setup or equivalent per-module
helpers so each test file only references its target module.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.github/workflows/release.yml:
- Around line 3-7: The workflow currently triggers on every push to master (the
on: push branches: - master block) which allows the tag-creation and
release-action steps to overwrite an existing release; change the trigger to
only run when package.json (or other versioned files) change (e.g., on: push
paths: [package.json] for master) OR add an early job step that checks diff of
package.json between HEAD and previous commit (using actions/checkout + git diff
HEAD^..HEAD -- package.json) and exits the job if there is no change; ensure
this check runs before the existing tag creation step and before the release
action so builds and releases are skipped for commits that don’t change the
version.
- Around line 132-139: The Docker job's job-level permissions only specify
"packages: write", which prevents actions/checkout@v4 from fetching the tag at
ref: refs/tags/${{ needs.release.outputs.new_version }}; update the job's
permissions block (the permissions: entry) to include contents: read alongside
packages: write so actions/checkout@v4 can read the repository contents and
checkout the tag.

In `@scripts/release.sh`:
- Around line 31-35: The release script currently runs only the unit tests via
"bun run test:unit", which lets route/OpenAPI regressions slip; update
scripts/release.sh to invoke the full test suite by running "bun test" (or add
"bun test" as an additional step after "bun run test:unit") so the script gates
release prep on the complete test suite, and ensure the command is executed
normally so nonzero exit codes cause the script to fail.

In `@src/server.ts`:
- Around line 839-847: The code in the WebSocket upgrade handling that sets
selectedSubprotocol from proto/offered (variables: proto, offered,
selectedSubprotocol, authReq) must not blindly echo credential-bearing tokens
like "apikey.", "bearer.", or "session." back in the Sec-WebSocket-Protocol
handshake; instead filter offered before choosing a subprotocol by first
removing any token that matches those credential prefixes and then selecting
only from a safe allowlist (or validated pattern) of legitimate subprotocol
names; apply the same filtering logic to the relay upgrade branch that also
reads proto and sets selectedSubprotocol so credential hints are never echoed in
ws.protocol, proxy or access logs.

In `@tests/routes/headless-optimizations.spec.ts`:
- Around line 184-190: The test creates the bootstrap promise (const bootstrap =
import(root + 'src/server.ts')) but never awaits it, so startup errors can occur
after the sleep; change the test to await the bootstrap promise (or replace the
sleep with a readiness poll) before importing getNip46Service and continuing,
ensuring any thrown error surfaces and fails the test; also move this test file
to be co-located with the code under test (e.g., into src/ as server.test.ts or
follow the co-location pattern used by src/routes/utils.test.ts) so it runs
alongside the modules it exercises.

---

Duplicate comments:
In `@docs/openapi/openapi.json`:
- Around line 2463-2527: The JSON OpenAPI bundle exposes the private field
"transport_sk" in the GET (operationId getNip46Transport) and PUT (operationId
updateNip46Transport) responses but the YAML model returns the public key;
update the response schemas for both operations to publish "transport_pubkey"
(string) instead of "transport_sk" so the bundled spec matches
docs/openapi/openapi.yaml and generated clients get the correct contract.

In `@docs/openapi/openapi.yaml`:
- Around line 119-125: Update the Authentication section for the /api/events
endpoint to remove any instructions that tell clients to place credentials in
the URL query params or in echoed Sec-WebSocket-Protocol subprotocol hints (the
bullet points mapping `apikey.<TOKEN>`, `bearer.<TOKEN>`, `session.<ID>` and the
legacy `apiKey`/`sessionId` note). Instead, state that /api/events uses the same
auth mechanisms as normal HTTP requests (API key header, Authorization: Bearer,
Basic, or session cookie/header) and explicitly forbid sending secrets in URL
query strings or in Sec-WebSocket-Protocol values to avoid credential leakage;
edit the "Authentication" block in openapi.yaml for /api/events accordingly.

In `@src/db/nip46.ts`:
- Around line 156-198: The nip46_requests migration block currently swallows
errors and allows initializeNip46DB() to succeed with a partially-upgraded
schema; change the flow so any exception during the nip46_requests repair (the
code that adds client_request_id, updates values, deletes duplicates,
drops/creates indexes using db.exec and hasTableColumn/hasIndex checks) is not
absorbed by the outer catch: either remove the local try/catch around this
branch or rethrow the caught error (throw err) so initializeNip46DB() fails;
apply the same behavior to the adjacent repair block referenced (lines ~199-202)
so any failure aborts initialization instead of letting the app continue with an
inconsistent schema.

In `@src/routes/nip46.ts`:
- Around line 17-27: parseJsonWithLimit currently calls await req.text() which
buffers the entire body before enforcing maxBytes; change it to enforce the
limit while streaming by reading from the request body reader (use req.body /
req.body.getReader()) and accumulate chunks, tracking byte length and returning
null (or throwing) as soon as accumulated bytes exceed maxBytes; alternatively,
if you prefer to reject unbounded bodies, make parseJsonWithLimit return null
immediately when content-length header is missing or not a safe number. Update
the parseJsonWithLimit function and references to req.text() accordingly so the
size check happens during streaming rather than after full buffering.

---

Nitpick comments:
In `@src/node/manager.ts`:
- Around line 2190-2239: The block that patches and restores
SimplePool.prototype.subscribeMany inside withSimplePoolSubscribeManyLock is too
nested; extract it into a helper (suggested name: withNormalizedSubscribeMany)
that accepts a callback and performs the try/patch/restore logic around
SimplePool.prototype.subscribeMany (creating normalizedSubscribeMany, capturing
originalSubscribeMany and restoreSubscribeMany, and logging patchError via
addServerLog), then call that helper to run createConnectedNode; update usages
to call withSimplePoolSubscribeManyLock(() => withNormalizedSubscribeMany(() =>
createConnectedNode(...))) or equivalent to keep the existing locking and
error-handling semantics while improving readability and testability.

In `@src/routes/update.ts`:
- Around line 55-59: The local parseBoolean in update.ts duplicates the boolean
parsing logic already used elsewhere; replace it by importing and using the
centralized boolean parser from src/const.ts (or move the parser into a shared
utility and export it from const.ts) instead of defining parseBoolean in
update.ts; update any references inside the update handler to call the exported
helper (e.g., boolean parser name from const.ts) and remove the local
parseBoolean function to avoid duplication and ensure consistent parsing across
HEADLESS/SKIP_ADMIN_SECRET_VALIDATION and routes/update logic.

In `@tests/routes/headless-optimizations.spec.ts`:
- Around line 1-265: The test file groups runtime specs for multiple modules
making ownership unclear; split each describe block into co-located
unit/integration tests next to the module they exercise: move SKIP_STARTUP_ECHO
tests to a new const.test.ts beside src/const.ts, move session/auth tests
(referencing AUTH_CONFIG and stopAuthCleanup) to tests next to
src/routes/auth.ts, and move NIP-46 tests (using initNip46Service and
getNip46Service and server bootstrap behavior) to tests next to
src/nip46/index.ts (and a short bootstrap.integration.test.ts near src/server.ts
if needed); rename files to *.test.ts or *.spec.ts, adjust imports to use
relative paths and retain runRouteScript setup or equivalent per-module helpers
so each test file only references its target module.

In `@tests/routes/update.spec.ts`:
- Around line 5-6: The test currently pins the expected version (e.g., "1.2.0")
which requires manual updates; change the test in tests/routes/update.spec.ts to
derive the expected version from the package.json that the route reads instead
of hardcoding it: read/require the repository package.json (using PROJECT_ROOT
to locate it) to get packageJson.version and use that value in the assertions
that currently compare against the pinned string (the assertions around lines
referencing 28-31), so the test uses the live package version for
expectedVersion.
- Around line 1-56: Move the test file tests/routes/update.spec.ts next to the
implementation src/routes/update.ts (e.g., src/routes/update.spec.ts or
src/routes/update.test.ts) so the route and its spec are co-located; rename the
file to follow the project's convention (feature.spec.ts or feature.test.ts) and
update any import paths or test runner helpers (runRouteScript usage) that
assume its former location to use the new relative path alongside
handleUpdateRoute in src/routes/update.ts.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5bd35ff6-8a1f-4419-8252-5b0b0b38aabd

📥 Commits

Reviewing files that changed from the base of the PR and between 3201d6a and 48da57c.

📒 Files selected for processing (22)
  • .github/workflows/ci.yml
  • .github/workflows/release.yml
  • .github/workflows/umbrel-dev.yml
  • docs/CONFIG.md
  • docs/DEPLOY.md
  • docs/RELEASE.md
  • docs/openapi/openapi.json
  • docs/openapi/openapi.yaml
  • env.example
  • package.json
  • scripts/release.sh
  • scripts/sync-version.mjs
  • src/db/nip46.ts
  • src/nip46/index.ts
  • src/node/manager.ts
  • src/routes/auth.ts
  • src/routes/nip46.ts
  • src/routes/update.ts
  • src/server.ts
  • tests/routes/headless-optimizations.spec.ts
  • tests/routes/nip46.spec.ts
  • tests/routes/update.spec.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • tests/routes/nip46.spec.ts
  • docs/DEPLOY.md
  • package.json

Comment on lines 3 to 7
on:
push:
branches: [ master ]
branches:
- master
workflow_dispatch:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

git ls-files ".github/workflows/*.yml"

Repository: FROSTR-ORG/igloo-server

Length of output: 154


🏁 Script executed:

cat -n ".github/workflows/release.yml"

Repository: FROSTR-ORG/igloo-server

Length of output: 7399


Don't trigger release workflow on every push to master without versioning changes.

Every push to master will execute the release job. When package.json hasn't changed, the version is reused from the previous release. At lines 89–90, tag creation is skipped if the tag already exists, but the job continues executing: it builds artifacts, and at lines 96–127 the release action creates or updates a release for that existing tag. This means multiple commits can publish under the same version tag—each overwriting the previous release's artifacts—making it impossible to track which commit corresponds to a released version.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/release.yml around lines 3 - 7, The workflow currently
triggers on every push to master (the on: push branches: - master block) which
allows the tag-creation and release-action steps to overwrite an existing
release; change the trigger to only run when package.json (or other versioned
files) change (e.g., on: push paths: [package.json] for master) OR add an early
job step that checks diff of package.json between HEAD and previous commit
(using actions/checkout + git diff HEAD^..HEAD -- package.json) and exits the
job if there is no change; ensure this check runs before the existing tag
creation step and before the release action so builds and releases are skipped
for commits that don’t change the version.

Comment on lines +132 to +139
permissions:
packages: write

steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: master
ref: refs/tags/${{ needs.release.outputs.new_version }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n .github/workflows/release.yml | head -160 | tail -50

Repository: FROSTR-ORG/igloo-server

Length of output: 1977


🏁 Script executed:

# Get the full context of the docker job
sed -n '100,150p' .github/workflows/release.yml

Repository: FROSTR-ORG/igloo-server

Length of output: 1682


🏁 Script executed:

# Check if there are any other permission patterns in the file
grep -n "permissions:" .github/workflows/release.yml -A 3

Repository: FROSTR-ORG/igloo-server

Length of output: 227


🌐 Web query:

actions/checkout v4 permissions contents:read required github actions

💡 Result:

actions/checkout@v4 needs the workflow job’s GITHUB_TOKEN to be able to read repository contents. If you’ve locked down token permissions (for least privilege), you must grant at least:

permissions:
  contents: read

Otherwise the checkout step can fail because any permission you don’t list is set to none. (github.com)

Notes:

  • contents: read is sufficient for “clone/build/test” workflows. (github.com)
  • If you plan to push commits/tags, you’ll need contents: write (and possibly additional scopes depending on what you do). (github.blog)

Citations:


🏁 Script executed:

# Check if there are any comments or documentation in the workflow file itself
grep -i "permission\|token\|contents\|read" .github/workflows/release.yml | head -20

Repository: FROSTR-ORG/igloo-server

Length of output: 438


Restore repository read permission in the Docker job.

Job-level permissions replace the defaults. With only packages: write here, actions/checkout@v4 cannot fetch the tag reference refs/tags/${{ needs.release.outputs.new_version }}. Add contents: read to enable the checkout step.

🔧 Minimal fix
   docker:
     runs-on: ubuntu-latest
     needs: release
     permissions:
+      contents: read
       packages: write
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/release.yml around lines 132 - 139, The Docker job's
job-level permissions only specify "packages: write", which prevents
actions/checkout@v4 from fetching the tag at ref: refs/tags/${{
needs.release.outputs.new_version }}; update the job's permissions block (the
permissions: entry) to include contents: read alongside packages: write so
actions/checkout@v4 can read the repository contents and checkout the tag.

Comment thread scripts/release.sh
Comment on lines +31 to +35
echo "🧪 Running type checks and backend tests..."
bun install --frozen-lockfile
bun run typecheck
bun run test:unit

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Gate release prep on the full bun test suite.

The PR verification steps use bun test, but this script only runs bun run test:unit. That means a release branch can be cut while route/OpenAPI regressions are still red.

🧪 Suggested change
-echo "🧪 Running type checks and backend tests..."
+echo "🧪 Running type checks and full test suite..."
 bun install --frozen-lockfile
 bun run typecheck
-bun run test:unit
+bun test
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
echo "🧪 Running type checks and backend tests..."
bun install --frozen-lockfile
bun run typecheck
bun run test:unit
echo "🧪 Running type checks and full test suite..."
bun install --frozen-lockfile
bun run typecheck
bun test
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/release.sh` around lines 31 - 35, The release script currently runs
only the unit tests via "bun run test:unit", which lets route/OpenAPI
regressions slip; update scripts/release.sh to invoke the full test suite by
running "bun test" (or add "bun test" as an additional step after "bun run
test:unit") so the script gates release prep on the complete test suite, and
ensure the command is executed normally so nonzero exit codes cause the script
to fail.

Comment thread src/server.ts
Comment on lines +839 to +847
// Parse optional credentials from Sec-WebSocket-Protocol (for non-browser clients)
const proto = req.headers.get('sec-websocket-protocol');
if (proto) {
const headers = new Headers(authReq.headers);
const offered = proto.split(',').map(p => p.trim()).filter(Boolean);
// Choose the first offered value to echo back (required by RFC6455)
if (offered.length > 0) {
selectedSubprotocol = offered[0];
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Read the specific line ranges mentioned in the review
echo "=== Lines 839-847 ===" && \
sed -n '839,847p' src/server.ts && \
echo && \
echo "=== Lines 929-937 ===" && \
sed -n '929,937p' src/server.ts

Repository: FROSTR-ORG/igloo-server

Length of output: 1210


🏁 Script executed:

# Get broader context around both line ranges to understand the code flow
echo "=== Context around lines 839-847 (WS auth path) ===" && \
sed -n '830,855p' src/server.ts && \
echo && \
echo "=== Context around lines 929-937 (relay upgrade path) ===" && \
sed -n '920,950p' src/server.ts

Repository: FROSTR-ORG/igloo-server

Length of output: 3112


🏁 Script executed:

# Search for any other instances of subprotocol handling or Sec-WebSocket-Protocol
rg -n "sec-websocket-protocol|subprotocol|selectedSubprotocol" src/server.ts -i -A 2 -B 2

Repository: FROSTR-ORG/igloo-server

Length of output: 1852


Don't echo credential-bearing subprotocol values back in the handshake response.

Both WebSocket upgrade paths blindly echo the first offered Sec-WebSocket-Protocol token back to the client. If a caller sends apikey.<token>, bearer.<token>, or session.<id> as an auth hint, that credential gets exposed in the handshake response, visible in ws.protocol, proxy logs, and access logs.

The event-stream path (lines 839–847) extracts credentials from these hints for authentication, then immediately echoes them back unfiltered. The relay path (lines 929–937) echoes them without even parsing. RFC 6455 requires echoing a negotiated subprotocol, but does not require echoing unvetted tokens from the offer.

Filter out credential-bearing tokens before echoing; only echo legitimate, allowlisted subprotocol names:

Safer negotiation pattern
-              if (offered.length > 0) {
-                selectedSubprotocol = offered[0];
-              }
+              const negotiableProtocols = offered.filter((token) => {
+                const lower = token.toLowerCase();
+                return !(
+                  lower.startsWith('apikey.') ||
+                  lower.startsWith('api-key.') ||
+                  lower.startsWith('bearer.') ||
+                  lower.startsWith('session.')
+                );
+              });
+              if (negotiableProtocols.length > 0) {
+                selectedSubprotocol = negotiableProtocols[0];
+              }

Apply the same filtering to the relay upgrade path (lines 929–937).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server.ts` around lines 839 - 847, The code in the WebSocket upgrade
handling that sets selectedSubprotocol from proto/offered (variables: proto,
offered, selectedSubprotocol, authReq) must not blindly echo credential-bearing
tokens like "apikey.", "bearer.", or "session." back in the
Sec-WebSocket-Protocol handshake; instead filter offered before choosing a
subprotocol by first removing any token that matches those credential prefixes
and then selecting only from a safe allowlist (or validated pattern) of
legitimate subprotocol names; apply the same filtering logic to the relay
upgrade branch that also reads proto and sets selectedSubprotocol so credential
hints are never echoed in ws.protocol, proxy or access logs.

Comment on lines +184 to +190
const bootstrap = import(root + 'src/server.ts');
bootstrap.catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
await new Promise((resolve) => setTimeout(resolve, 1000));
const { getNip46Service } = await import(root + 'src/nip46/index.ts');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check the file exists and get basic info
file_path="tests/routes/headless-optimizations.spec.ts"
if [ -f "$file_path" ]; then
  echo "File found. Line count:"
  wc -l "$file_path"
else
  echo "File not found at $file_path"
  # Try to find it
  find . -name "headless-optimizations.spec.ts" -type f 2>/dev/null
fi

Repository: FROSTR-ORG/igloo-server

Length of output: 138


🏁 Script executed:

# Read the specific lines mentioned: 184-190 and 213-219
echo "=== Lines 184-190 ==="
sed -n '184,190p' tests/routes/headless-optimizations.spec.ts

echo -e "\n=== Lines 213-219 ==="
sed -n '213,219p' tests/routes/headless-optimizations.spec.ts

Repository: FROSTR-ORG/igloo-server

Length of output: 833


🏁 Script executed:

# Get more context around those lines to understand the test structure
echo "=== Lines 175-225 (full test context) ==="
sed -n '175,225p' tests/routes/headless-optimizations.spec.ts

Repository: FROSTR-ORG/igloo-server

Length of output: 2058


🏁 Script executed:

# Check the file location and see what modules it's testing
echo "=== File location and imports ==="
head -30 tests/routes/headless-optimizations.spec.ts

Repository: FROSTR-ORG/igloo-server

Length of output: 1097


🏁 Script executed:

# Check if there's a co-located test file near the modules being tested
echo "=== Checking for co-located tests ==="
find src -name "*.test.ts" -o -name "*.spec.ts" 2>/dev/null | head -20

Repository: FROSTR-ORG/igloo-server

Length of output: 181


🏁 Script executed:

# Check the directory structure
echo "=== Directory structure ==="
tree -L 3 src/ 2>/dev/null || find src -type d | head -20
tree -L 3 tests/ 2>/dev/null || find tests -type d | head -20

Repository: FROSTR-ORG/igloo-server

Length of output: 2596


Await the bootstrap import; tests can pass after late startup failures.

Both test cases at lines 184–190 and 213–219 create const bootstrap = import(...) but never await it before proceeding. The .catch() handler suppresses errors without blocking; the subsequent await new Promise(..., 1000) only sleeps—it does not await the bootstrap. If the server fails to initialize within that second window, the test can still report success. Await the bootstrap promise before continuing, or poll for readiness to confirm startup completed successfully.

Additionally, this test file should be co-located with the modules it exercises. Move it to src/ alongside the code under test (e.g., src/server.test.ts or restructure to align with the pattern used for other co-located tests like src/routes/utils.test.ts).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/routes/headless-optimizations.spec.ts` around lines 184 - 190, The test
creates the bootstrap promise (const bootstrap = import(root + 'src/server.ts'))
but never awaits it, so startup errors can occur after the sleep; change the
test to await the bootstrap promise (or replace the sleep with a readiness poll)
before importing getNip46Service and continuing, ensuring any thrown error
surfaces and fails the test; also move this test file to be co-located with the
code under test (e.g., into src/ as server.test.ts or follow the co-location
pattern used by src/routes/utils.test.ts) so it runs alongside the modules it
exercises.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
package.json (1)

39-40: Redundant scripts: typecheck and tsc are identical.

Both scripts execute tsc --noEmit. Consider removing one or differentiating their purposes (e.g., tsc for full build, typecheck for validation).

♻️ Suggested fix
-    "typecheck": "tsc --noEmit",
-    "tsc": "tsc --noEmit"
+    "typecheck": "tsc --noEmit"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` around lines 39 - 40, The package.json contains duplicate npm
scripts "typecheck" and "tsc" that both run "tsc --noEmit"; remove or
differentiate them to avoid redundancy: either delete one of the scripts (keep
only "typecheck" or only "tsc") or change "tsc" to perform a full compile/build
(e.g., "tsc" -> run the compiler without --noEmit or an actual build step) while
keeping "typecheck" as "tsc --noEmit" so their purposes are distinct.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@package.json`:
- Around line 39-40: The package.json contains duplicate npm scripts "typecheck"
and "tsc" that both run "tsc --noEmit"; remove or differentiate them to avoid
redundancy: either delete one of the scripts (keep only "typecheck" or only
"tsc") or change "tsc" to perform a full compile/build (e.g., "tsc" -> run the
compiler without --noEmit or an actual build step) while keeping "typecheck" as
"tsc --noEmit" so their purposes are distinct.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 622b4fea-6cb4-4b36-bc74-24261ea40582

📥 Commits

Reviewing files that changed from the base of the PR and between 48da57c and d2db2e9.

📒 Files selected for processing (1)
  • package.json

AustinKelsay and others added 10 commits May 28, 2026 11:16
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
… milestone

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…api/nip44/*

Threshold ECDH on Bifrost returns the raw shared X coordinate, which the
previous /api/nip44/encrypt and /api/nip44/decrypt handlers used directly
as the NIP-44 conversation key. That broke interoperability with every
standards-compliant NIP-44 peer.

Adds a shared deriveNip44ConversationKey helper that applies the spec
HKDF-extract step (SHA-256, salt='nip44-v2') to the raw shared X before
invoking nostr-tools' nip44.encrypt/decrypt. The HTTP route now produces
ciphertext decryptable by external NIP-44 peers and accepts externally
generated standards-compliant ciphertext, while preserving auth,
body-size, rate-limit, method, node-availability, and ECDH-timeout
semantics.

Released OpenAPI for /api/nip44/* now documents the real POST-only/auth
contract surface (401, 405, 413, 500 plus the existing 200/400/429/
503/504), tightens the peer_pubkey pattern to x-only or 02/03-prefixed
compressed forms, and decouples the NIP-04 schema so no NIP-04 behavior
is broadened.

Adds an interop-first test suite (tests/routes/nip44.spec.ts) that uses
nostr-tools as an independent oracle to prove standards compliance,
covers the negative legacy/raw-secret/non-v2/malformed cases, and
exercises the contract semantics required by the released API.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
- Test suite: 140 pass / 0 fail
- Typecheck, build, docs:validate all green
- Review: http-nip44-standards-interop passes
- Applied library updates: defense-in-depth auth behavior, secret scanner heuristic workaround

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
All 8 NIP-44 API assertions passed via Bun contract tests:
- VAL-NIP44-API-001: encrypt produces standards-compliant ciphertext
- VAL-NIP44-API-002: decrypt accepts externally generated ciphertext
- VAL-NIP44-API-003: decrypt rejects malformed/non-v2/legacy ciphertext
- VAL-NIP44-API-004: auth gating preserved (401 for unauthenticated)
- VAL-NIP44-API-005: encrypt/decrypt preserve supported peer key shapes
- VAL-NIP44-API-006: HTTP method and operational failure semantics correct
- VAL-NIP44-API-007: OpenAPI peer_pubkey schema matches runtime
- VAL-NIP44-API-008: OpenAPI documents real auth and response contract

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…44_* RPC

Threshold ECDH on Bifrost returns the raw shared X coordinate, which the
previous NIP-46 `nip44_encrypt` and `nip44_decrypt` handlers in
src/nip46/service.ts used directly as the NIP-44 conversation key. That
mirrored the same disclosure already fixed for /api/nip44/* and broke
interop with every standards-compliant NIP-44 peer on the NIP-46 RPC
surface as well.

The fix reuses the shared deriveNip44ConversationKey helper (NIP-44 v2
HKDF-extract with salt 'nip44-v2') in src/routes/crypto-utils.ts, so the
HTTP /api/nip44/* and NIP-46 nip44_* surfaces now derive the exact same
spec conversation key from the same signer/peer ECDH relationship and
cannot drift again. NIP-04 paths are untouched. No legacy raw-secret
fallback is introduced.

Adds relay-backed-style interop coverage:

- tests/routes/nip46.spec.ts gains a `NIP-46 nip44 RPC standards-compliant
  interop` block covering oracle parity, encrypt/decrypt happy paths
  against nostr-tools as the standards oracle, negative cases
  (raw-shared-secret control, malformed, non-v2) on an allowed session,
  mixed x-only/02/03 peer shapes, missing/empty param errors, request-id
  preservation on success and on failure, and the contract-required
  method-specific policy matrix (encrypt-only, decrypt-only, both
  granted) proving that ungranted nip44_* requests stay pending while
  granted ones auto-approve via shouldAutoApprove + processApprovedRequest.

- tests/routes/protected.api.spec.ts gains cross-surface assertions
  proving that ciphertext produced by /api/nip44/encrypt decrypts via
  NIP-46 nip44_decrypt and vice versa on a shared signer instance, with
  mixed x-only and compressed (02-prefixed) peer key forms, plus an
  external-peer standards ciphertext that decrypts on both surfaces.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
- Validators pass: 155 tests, typecheck, build, docs:validate
- Review subagent verdict: pass for nip46-nip44-rpc-standards-interop
- Applied library updates: architecture.md NIP-44 SSoT note, new testing.md

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…estone

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
@AustinKelsay
AustinKelsay merged commit 918cfa3 into master May 28, 2026
5 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant